Strata/strata@edu4rdshl.dev/ui/clipboardItem.js
Eduard Tolosa 945ad61bf7 feat: light/dark theme support
Add a Theme preference (Automatic / Light / Dark). Automatic follows the
system org.gnome.desktop.interface color-scheme; the dark theme is
unchanged. Light styling lives in light.css, every rule scoped under a
.strata-theme-light class the panel toggles on its root box, loaded into the
St theme context by the extension. St's CSS engine has no custom properties,
so this specificity-based scoped override is how the palette is switched
without generating a stylesheet at runtime; switching is a single class
toggle off the ingest/render hot paths.

Also in this change:
- fix: the keyboard shortcut now hides the panel when it is already open.
  The binding lacked Shell.ActionMode.POPUP, so while the panel's modal grab
  was active the second press was swallowed and toggle() never ran.
- fix: the search placeholder ("Search...") is now legible in the light
  theme; it previously inherited a light-on-dark Shell color.
- pack: bundle light.css via --extra-source (gnome-extensions pack only
  auto-includes stylesheet.css), so packaged installs ship the light theme.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:49:08 -05:00

264 lines
9.1 KiB
JavaScript

/* clipboardItem.js - row widget for the clipboard list. */
import GObject from 'gi://GObject';
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
const TEXT_PREVIEW_LEN = 140;
const THUMB_SIZE = 48;
const ICON_BY_MIME = {
'text/uri-list': 'emblem-web-symbolic',
'text/html': 'text-html-symbolic',
'image/': 'image-x-generic-symbolic',
};
function iconForMime(mimeType) {
for (const [prefix, icon] of Object.entries(ICON_BY_MIME)) {
if (mimeType.startsWith(prefix)) return icon;
}
return 'edit-copy-symbolic';
}
/** Detect if a string looks like a URL. */
function isUrl(text) {
return /^https?:\/\/.+/i.test(text.trim());
}
/** Detect if a string looks like a CSS/hex color. */
function isColor(text) {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
}
export const ClipboardItem = GObject.registerClass({
Signals: {
'activate': {},
'delete': {},
},
}, class ClipboardItem extends St.Button {
_init(id, mimeType, preview, opts = {}) {
super._init({
style_class: 'strata-item',
x_expand: true,
can_focus: true,
reactive: true,
});
this._id = id;
this._mimeType = mimeType;
/** D-Bus proxy used for lazy thumbnail fetch. */
this._proxy = opts.proxy ?? null;
/** Optional shared LRU cache (Map<id, string filePath>) - populated on first fetch. */
this._thumbCache = opts.thumbCache ?? null;
/** Stored on the actor so panel.js can filter by it. */
this.actor = this;
this._strataPreview = mimeType.startsWith('image/') ? '' : preview;
const row = new St.BoxLayout({
style_class: 'strata-item-row',
x_expand: true,
});
// Left: icon or image thumbnail
row.add_child(this._buildLeading(mimeType, preview));
// Center: text preview
row.add_child(this._buildContent(mimeType, preview));
// Right: delete button
const deleteBtn = new St.Button({
style_class: 'strata-item-delete',
icon_name: 'edit-delete-symbolic',
y_align: Clutter.ActorAlign.CENTER,
});
deleteBtn.connect('clicked', () => {
this.emit('delete');
return Clutter.EVENT_STOP;
});
row.add_child(deleteBtn);
this.set_child(row);
this.connect('clicked', () => this.emit('activate'));
// Make the item text bold when keyboard-focused so the selected item
// is immediately obvious during arrow-key navigation.
// We add/remove an explicit style class because CSS :focus pseudo-class
// can be unreliable in GNOME Shell extensions. The bold + focus text
// color live in CSS (.strata-item-focused .strata-item-text) so each
// theme (dark/light) can color them; we only toggle the class here.
this.connect('key-focus-in', () => {
this.add_style_class_name('strata-item-focused');
});
this.connect('key-focus-out', () => {
this.remove_style_class_name('strata-item-focused');
});
}
_buildLeading(mimeType, preview) {
if (mimeType.startsWith('image/')) {
return this._buildThumbnail(this._id);
}
if (isColor(preview)) {
return this._buildColorSwatch(preview);
}
const icon = new St.Icon({
icon_name: iconForMime(mimeType),
icon_size: 20,
style_class: 'strata-item-icon',
y_align: Clutter.ActorAlign.CENTER,
});
return icon;
}
/** Build an image thumbnail container.
* - If already in shared cache, apply immediately.
* - Else fetch via GetThumbnailRemote, write to ~/.cache/strata/thumbnails/{id}.png,
* then apply. The decode happens off-thread (CSS background-image loader). */
_buildThumbnail(id) {
const container = new St.Widget({
width: THUMB_SIZE,
height: THUMB_SIZE,
style_class: 'strata-item-thumb',
y_align: Clutter.ActorAlign.CENTER,
});
const cacheDir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
const cachePath = `${cacheDir}/${id}.png`;
const fileUri = `file://${cachePath}`;
const applyStyle = () => {
try {
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
} catch (_) { /* container was destroyed mid-flight */ }
};
try {
// Fast path: already cached this session.
if (this._thumbCache?.has(id)) {
applyStyle();
return container;
}
// Second fast path: file exists on disk from a previous session.
if (GLib.file_test(cachePath, GLib.FileTest.EXISTS)) {
this._thumbCache?.set(id, cachePath);
applyStyle();
return container;
}
// Slow path: ask the daemon for the bytes, then write file async.
GLib.mkdir_with_parents(cacheDir, 0o755);
if (!this._proxy) {
this._fallbackIcon(container);
return container;
}
this._proxy.GetThumbnailRemote(id, ([bytes]) => {
try {
if (!container.get_parent()) return; // destroyed
if (!bytes || bytes.length === 0) {
this._fallbackIcon(container);
return;
}
const file = Gio.File.new_for_path(cachePath);
file.replace_contents_bytes_async(
new GLib.Bytes(bytes),
null,
false,
Gio.FileCreateFlags.NONE,
null,
(_f, result) => {
try {
_f.replace_contents_finish(result);
this._thumbCache?.set(id, cachePath);
applyStyle();
} catch (e) {
console.error('[Strata] Thumbnail write error:', e);
this._fallbackIcon(container);
}
}
);
} catch (e) {
console.error('[Strata] Thumbnail fetch handler error:', e);
this._fallbackIcon(container);
}
});
} catch (e) {
console.error('[Strata] Thumbnail render error:', e);
this._fallbackIcon(container);
}
return container;
}
_fallbackIcon(container) {
if (container.get_n_children() > 0) return;
try {
const icon = new St.Icon({
icon_name: 'image-x-generic-symbolic',
icon_size: 20,
y_align: Clutter.ActorAlign.CENTER,
x_align: Clutter.ActorAlign.CENTER,
});
container.add_child(icon);
} catch (_) {}
}
_buildColorSwatch(hex) {
return new St.Widget({
width: 24,
height: 24,
style: `background-color: ${hex}; border-radius: 4px; border: 1px solid rgba(0,0,0,0.2);`,
style_class: 'strata-item-swatch',
y_align: Clutter.ActorAlign.CENTER,
});
}
_buildContent(mimeType, preview) {
preview = preview ?? '';
const box = new St.BoxLayout({
vertical: true,
x_expand: true,
style_class: 'strata-item-content',
});
let mainText = '';
let subText = '';
if (mimeType.startsWith('image/')) {
mainText = mimeType === 'image/png' ? 'PNG image' : 'Image';
} else if (isUrl(preview)) {
mainText = preview.trim();
try {
subText = new URL(preview.trim()).hostname;
} catch (_) {}
} else if (isColor(preview)) {
mainText = preview.trim().toUpperCase();
subText = 'Color';
} else {
const trimmed = preview.replace(/\s+/g, ' ').trim();
mainText = trimmed.length > TEXT_PREVIEW_LEN
? trimmed.slice(0, TEXT_PREVIEW_LEN) + '…'
: trimmed;
}
const labelMain = new St.Label({
text: mainText || '(empty)',
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
labelMain.clutter_text.line_wrap = false;
labelMain.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END
box.add_child(labelMain);
if (subText) {
const labelSub = new St.Label({
text: subText,
style_class: 'strata-item-subtext',
x_expand: true,
});
box.add_child(labelSub);
}
return box;
}
});