From c9531e0cf4d608f9ddca6522a139d0afd456af25 Mon Sep 17 00:00:00 2001 From: Francesco Boccola Date: Wed, 27 May 2026 23:52:34 +0200 Subject: [PATCH] nemo-terminal: add appearance settings, Gogh themes, and a preferences UI Add terminal appearance configuration with live updates and theme switching, exposed through the existing preferences window. - GSettings: new keys for font, foreground/background/cursor colors, cursor shape (block/ibeam/underline), 16-color palette, and scrollback lines. - nemo_terminal.py: apply these via VTE (set_font/set_colors/set_color_cursor/ set_cursor_shape/set_scrollback_lines), connected to changed:: so edits take effect live in open terminals; reads are guarded against schema/code skew. A "Preferences" item in the terminal context menu launches the prefs GUI. - nemo-terminal-prefs.py: new "Appearance" page (font, colors, cursor shape, scrollback) plus a "Change theme (powered by Gogh)" picker with type-to-filter. - tools/nemo-terminal-theme: import any Gogh theme into the palette/colors, packaged via setup.py. Co-Authored-By: Claude Opus 4.7 --- nemo-terminal/setup.py | 3 +- nemo-terminal/src/nemo-terminal-prefs.py | 173 +++++++++++++++++- nemo-terminal/src/nemo_terminal.py | 115 ++++++++++-- ....nemo.extensions.nemo-terminal.gschema.xml | 52 +++++- nemo-terminal/tools/nemo-terminal-theme | 98 ++++++++++ 5 files changed, 422 insertions(+), 19 deletions(-) create mode 100755 nemo-terminal/tools/nemo-terminal-theme diff --git a/nemo-terminal/setup.py b/nemo-terminal/setup.py index 75f2e9fe..594b2f29 100644 --- a/nemo-terminal/setup.py +++ b/nemo-terminal/setup.py @@ -28,7 +28,8 @@ data_files = [ ('/usr/share/nemo-python/extensions', ['src/nemo_terminal.py']), - ('/usr/bin', ['src/nemo-terminal-prefs']), + ('/usr/bin', ['src/nemo-terminal-prefs', + 'tools/nemo-terminal-theme']), ('/usr/share/nemo-terminal', ['src/nemo-terminal-prefs.py', 'pixmap/logo_120x120.png']), ('/usr/share/glib-2.0/schemas', ['src/org.nemo.extensions.nemo-terminal.gschema.xml']) diff --git a/nemo-terminal/src/nemo-terminal-prefs.py b/nemo-terminal/src/nemo-terminal-prefs.py index 2c81013d..63018d1b 100755 --- a/nemo-terminal/src/nemo-terminal-prefs.py +++ b/nemo-terminal/src/nemo-terminal-prefs.py @@ -4,9 +4,13 @@ gi.require_version('Gtk', '3.0') gi.require_version('XApp', '1.0') import sys +import os +import shutil +import subprocess +import threading import gettext -from gi.repository import Gtk, Gio, XApp, Gdk +from gi.repository import Gtk, Gio, XApp, Gdk, GLib # i18n import gettext @@ -49,6 +53,7 @@ def __init__(self): self.connect("destroy", Gtk.main_quit) self.settings = Gio.Settings(schema_id="org.nemo.extensions.nemo-terminal") + self._theme_tool = self._resolve_theme_tool() # Basic @@ -151,6 +156,98 @@ def on_accel_cleared(accel, path, data=None): self.add_page(page, "main", _("Basic")) + # Appearance + + page = Page() + + frame = Gtk.Frame() + frame.get_style_context().add_class("view") + page.add(frame) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + frame.add(box) + + fontbutton = Gtk.FontButton() + self.settings.bind("terminal-font", + fontbutton, "font", + Gio.SettingsBindFlags.DEFAULT) + box.pack_start(LabeledItem(_("Font (blank for system monospace)"), fontbutton), + False, False, 6) + + box.pack_start(LabeledItem(_("Foreground color"), + self._make_color_button("terminal-foreground-color")), + False, False, 6) + box.pack_start(LabeledItem(_("Background color"), + self._make_color_button("terminal-background-color")), + False, False, 6) + box.pack_start(LabeledItem(_("Cursor color"), + self._make_color_button("terminal-cursor-color")), + False, False, 6) + + combo = Gtk.ComboBoxText() + combo.append("block", _("Block")) + combo.append("ibeam", _("Beam")) + combo.append("underline", _("Underline")) + self.settings.bind("terminal-cursor-shape", + combo, "active-id", + Gio.SettingsBindFlags.DEFAULT) + box.pack_start(LabeledItem(_("Cursor shape"), combo), False, False, 6) + + spinner = Gtk.SpinButton.new_with_range(-1, 1000000, 100) + spinner.set_digits(0) + self.settings.bind("terminal-scrollback-lines", + spinner, "value", + Gio.SettingsBindFlags.DEFAULT) + box.pack_start(LabeledItem(_("Scrollback lines (-1 for unlimited)"), spinner), + False, False, 6) + + # Gogh theme picker + + frame = Gtk.Frame() + frame.get_style_context().add_class("view") + page.add(frame) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + frame.add(box) + + self._theme_combo = Gtk.ComboBoxText.new_with_entry() + + # Type-to-filter over the (long) theme list: a substring-matching + # EntryCompletion on the combo's entry, so typing e.g. "mocha" narrows + # the suggestions instead of scrolling hundreds of names. + self._theme_store = Gtk.ListStore(str) + completion = Gtk.EntryCompletion() + completion.set_model(self._theme_store) + completion.set_text_column(0) + completion.set_match_func(self._theme_match_func, self._theme_store) + completion.set_popup_completion(True) + self._theme_combo.get_child().set_completion(completion) + + apply_button = Gtk.Button(_("Apply theme")) + apply_button.set_valign(Gtk.Align.CENTER) + apply_button.connect("clicked", self._on_apply_theme) + + theme_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + theme_row.pack_start(self._theme_combo, True, True, 0) + theme_row.pack_end(apply_button, False, False, 6) + box.pack_start(LabeledItem(_("Change theme (powered by Gogh)"), theme_row), + False, False, 6) + + self._theme_status = Gtk.Label(label="") + self._theme_status.set_line_wrap(True) + self._theme_status.set_xalign(0.0) + box.pack_start(self._theme_status, False, False, 6) + + if self._theme_tool: + threading.Thread(target=self._load_theme_list, daemon=True).start() + else: + self._theme_combo.set_sensitive(False) + apply_button.set_sensitive(False) + self._theme_status.set_text( + _("'nemo-terminal-theme' not found — install it to switch themes.")) + + self.add_page(page, "appearance", _("Appearance")) + # Advanced page = Page() @@ -228,6 +325,80 @@ def on_reset_clicked(button, data=None): self.present() + def _make_color_button(self, key): + """A ColorButton kept in sync with a string GSettings color key. + + ColorButton exposes a Gdk.RGBA property, not a string, so we bridge it + by hand: load the key into the button, write the button's color back on + change, and reload when the key changes elsewhere (e.g. a theme apply).""" + button = Gtk.ColorButton() + button.set_use_alpha(False) + + def load(*args): + rgba = Gdk.RGBA() + if rgba.parse(self.settings.get_string(key)): + button.set_rgba(rgba) + + load() + button.connect("color-set", + lambda b: self.settings.set_string(key, b.get_rgba().to_string())) + self.settings.connect("changed::" + key, load) + return button + + def _resolve_theme_tool(self): + """Locate the nemo-terminal-theme importer (PATH, repo tools/, or /usr/bin).""" + found = shutil.which("nemo-terminal-theme") + if found: + return found + here = os.path.dirname(os.path.abspath(__file__)) + for candidate in (os.path.join(here, "..", "tools", "nemo-terminal-theme"), + "/usr/bin/nemo-terminal-theme"): + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return os.path.abspath(candidate) + return None + + def _load_theme_list(self): + """Populate the theme dropdown from `nemo-terminal-theme --list` (off-thread).""" + try: + proc = subprocess.run([self._theme_tool, "--list"], + capture_output=True, text=True, timeout=30) + names = [n for n in proc.stdout.splitlines() if n.strip()] \ + if proc.returncode == 0 else [] + except Exception: + names = [] + GLib.idle_add(self._populate_theme_combo, names) + + def _populate_theme_combo(self, names): + for name in names: + self._theme_combo.append_text(name) + self._theme_store.append([name]) + return False + + def _theme_match_func(self, completion, key, iter_, store): + # Match the typed text anywhere in the theme name (case-insensitive). + # GTK passes `key` already case-folded. + return key in store[iter_][0].lower() + + def _on_apply_theme(self, button): + name = (self._theme_combo.get_active_text() or "").strip() + if not name: + self._theme_status.set_text(_("Enter or pick a theme name first.")) + return + self._theme_status.set_text(_("Applying '%s'…") % name) + threading.Thread(target=self._apply_theme_thread, + args=(name,), daemon=True).start() + + def _apply_theme_thread(self, name): + try: + proc = subprocess.run([self._theme_tool, name], + capture_output=True, text=True, timeout=60) + ok = proc.returncode == 0 + output = (proc.stdout if ok else proc.stderr).strip().splitlines() + msg = output[-1] if output else (_("done") if ok else _("failed")) + except Exception as exc: + ok, msg = False, str(exc) + GLib.idle_add(self._theme_status.set_text, ("✓ " if ok else "✗ ") + msg) + def quit(self, *args): self.destroy() Gtk.main_quit() diff --git a/nemo-terminal/src/nemo_terminal.py b/nemo-terminal/src/nemo_terminal.py index 568af961..112c0f96 100755 --- a/nemo-terminal/src/nemo_terminal.py +++ b/nemo-terminal/src/nemo_terminal.py @@ -40,6 +40,8 @@ import os import sys +import shutil +import subprocess from signal import SIGTERM, SIGKILL import signal signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -58,7 +60,8 @@ import gi gi.require_version('Vte', '2.91') gi.require_version('Nemo', '3.0') -from gi.repository import GObject, Nemo, Gtk, Gdk, Vte, GLib, Gio +gi.require_version('Pango', '1.0') +from gi.repository import GObject, Nemo, Gtk, Gdk, Vte, GLib, Gio, Pango BASE_KEY = "org.nemo.extensions.nemo-terminal" settings = Gio.Settings.new(BASE_KEY) @@ -88,6 +91,18 @@ def __init__(self, uri, window): settings.bind("audible-bell", self.term, "audible-bell", Gio.SettingsBindFlags.GET) + # Appearance (font/colors/scrollback). Connect on the shared settings object + # so a value change live-updates every open terminal; disconnected in destroy(). + self._appearance_handler_ids = [] + self._apply_appearance() + for key in ("terminal-font", "terminal-foreground-color", + "terminal-background-color", "terminal-cursor-color", + "terminal-cursor-shape", "terminal-palette", + "terminal-scrollback-lines"): + hid = settings.connect("changed::%s" % key, + lambda *a: self._apply_appearance()) + self._appearance_handler_ids.append(hid) + self.shell_pid = self.term.spawn_sync(Vte.PtyFlags.DEFAULT, self._path, [terminal_or_default()], None, GLib.SpawnFlags.SEARCH_PATH, None, None, None)[1] # Make vte.sh active @@ -153,23 +168,15 @@ def __init__(self, uri, window): menu_item_pastefilenames.set_label(_("Paste Filenames")) self.menu_item_pastefilenames = menu_item_pastefilenames self.menu.add(menu_item_pastefilenames) - #MenuItem => separator #TODO: Implement the preferences window - #menu_item = Gtk.SeparatorMenuItem() - #self.menu.add(menu_item) - #MenuItem => preferences - #menu_item = Gtk.ImageMenuItem.new_from_stock("gtk-preferences", None) - #self.menu.add(menu_item) - #MenuItem => separator - #menu_item = Gtk.SeparatorMenuItem() - #self.menu.add(menu_item) - #MenuItem => Goto current terminal directory - #menu_item = Gtk.MenuItem.new_with_label(_("Goto current terminal directory")) - #menu_item.connect_after("activate", - # lambda w: self._goto_current_terminal_directory()) - #self.menu.add(menu_item) #MenuItem => separator menu_item = Gtk.SeparatorMenuItem() self.menu.add(menu_item) + #MenuItem => Preferences + menu_item = Gtk.ImageMenuItem.new_from_stock("gtk-preferences", None) + menu_item.set_label(_("Preferences")) + menu_item.connect_after("activate", + lambda w: self._open_preferences()) + self.menu.add(menu_item) #MenuItem => About menu_item = Gtk.ImageMenuItem.new_from_stock("gtk-about", None) menu_item.connect_after("activate", @@ -303,6 +310,25 @@ def set_visible(self, visible): else: self.hbox.hide() + def _open_preferences(self): + """Launch the preferences GUI from the terminal's context menu. + + Prefer the installed `nemo-terminal-prefs` launcher on PATH; fall back + to running the implementation directly from its installed location or + from the source tree (when developing against a symlinked extension). + """ + launcher = shutil.which("nemo-terminal-prefs") + if launcher: + subprocess.Popen([launcher]) + return + here = os.path.dirname(os.path.realpath(__file__)) + for script in (os.path.join(here, "nemo-terminal-prefs.py"), + "/usr/share/nemo-terminal/nemo-terminal-prefs.py"): + if os.path.exists(script): + subprocess.Popen(["/usr/bin/python3", script]) + return + print("[%s] W: nemo-terminal-prefs not found" % __app_disp_name__) + def show_about_dialog(self): """Display the about dialog.""" about_dlg = Gtk.AboutDialog() @@ -323,6 +349,10 @@ def show_about_dialog(self): def destroy(self): """Release widgets and the shell process.""" + #Disconnect live appearance handlers from the shared settings object + for hid in self._appearance_handler_ids: + settings.disconnect(hid) + self._appearance_handler_ids = [] #Terminate the shell self._respawn_lock = True try: @@ -364,6 +394,61 @@ def _uri_to_path(self, uri): else: return "" + def _apply_appearance(self): + """Apply font, colors and scrollback from GSettings to the VTE widget. + + Called once at construction and on every change of an appearance key, so + edits take effect live in already-open terminals. Blank/unparsable values + leave the corresponding VTE default untouched rather than erroring. + """ + # Reading an absent key aborts the process, so guard every read against + # code/schema version skew (e.g. an updated .py but the system schema + # not yet recompiled): a missing key just falls back to its default. + schema = settings.get_property("settings-schema") + + def _get(getter, key, default): + return getattr(settings, getter)(key) if schema.has_key(key) else default + + font = _get("get_string", "terminal-font", "") + if font: + self.term.set_font(Pango.FontDescription.from_string(font)) + else: + self.term.set_font(None) + + fg = Gdk.RGBA() + fg_ok = fg.parse(_get("get_string", "terminal-foreground-color", "")) + bg = Gdk.RGBA() + bg_ok = bg.parse(_get("get_string", "terminal-background-color", "")) + + # A full theme also needs the ANSI palette. When a valid-sized palette is + # present, set everything in one call so foreground/background and the + # 16 (or 8/232/256) palette colors stay consistent; otherwise just apply + # the individual foreground/background colors. + palette = [] + for color in _get("get_strv", "terminal-palette", []): + rgba = Gdk.RGBA() + if rgba.parse(color): + palette.append(rgba) + if len(palette) in (8, 16, 232, 256): + self.term.set_colors(fg if fg_ok else None, + bg if bg_ok else None, + palette) + else: + if fg_ok: + self.term.set_color_foreground(fg) + if bg_ok: + self.term.set_color_background(bg) + + cursor = Gdk.RGBA() + if cursor.parse(_get("get_string", "terminal-cursor-color", "")): + self.term.set_color_cursor(cursor) + + # Cursor shape: enum values match Vte.CursorShape (block=0, ibeam=1, underline=2) + self.term.set_cursor_shape( + Vte.CursorShape(_get("get_enum", "terminal-cursor-shape", 0))) + + self.term.set_scrollback_lines(_get("get_int", "terminal-scrollback-lines", 10000)) + def _set_term_height(self, height): """Change the terminal height. diff --git a/nemo-terminal/src/org.nemo.extensions.nemo-terminal.gschema.xml b/nemo-terminal/src/org.nemo.extensions.nemo-terminal.gschema.xml index 97522675..448e9d85 100644 --- a/nemo-terminal/src/org.nemo.extensions.nemo-terminal.gschema.xml +++ b/nemo-terminal/src/org.nemo.extensions.nemo-terminal.gschema.xml @@ -11,8 +11,14 @@ - - + + + + + + @@ -75,5 +81,47 @@ Change directory command The command used by the terminal to change directory. %s is replaced by the shell-quoted directory name in the shell. This command will have a new line appended. + + + "" + Terminal font + Pango font description used by the terminal, e.g. "Monospace 11". Blank uses the system monospace font. + + + + "" + Terminal foreground color + Text color, as any string Gdk.RGBA can parse, e.g. "#d3d7cf" or "white". Blank uses the VTE default. + + + + "" + Terminal background color + Background color, as any string Gdk.RGBA can parse, e.g. "#1e1e2e" or "black". Blank uses the VTE default. + + + + "" + Terminal cursor color + Cursor color, as any string Gdk.RGBA can parse, e.g. "#f5e0dc". Blank uses the VTE default. + + + + "block" + Cursor shape + Shape of the terminal cursor: "block" (classic), "ibeam" (beam) or "underline". + + + + [] + Terminal color palette + The ANSI color palette as a list of color strings Gdk.RGBA can parse (e.g. "#45475a"). A full theme uses 16 entries (the 8 normal + 8 bright colors); 8, 232 or 256 entries are also accepted. Empty leaves the VTE default palette and only the foreground/background keys apply. + + + + 10000 + Scrollback lines + Number of scrollback lines the terminal keeps. Use -1 for unlimited. + diff --git a/nemo-terminal/tools/nemo-terminal-theme b/nemo-terminal/tools/nemo-terminal-theme new file mode 100755 index 00000000..bca480e3 --- /dev/null +++ b/nemo-terminal/tools/nemo-terminal-theme @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# nemo-terminal-theme — apply a Gogh color theme to nemo-terminal. +# +# Gogh (https://github.com/Gogh-Co/Gogh) ships each theme as a small YAML file +# defining 16 ANSI palette colors plus background/foreground/cursor. The Gogh +# installer only knows how to configure a fixed set of terminals, so this script +# fetches a theme's color definition directly and writes it into nemo-terminal's +# GSettings (org.nemo.extensions.nemo-terminal) instead. +# +# Usage: +# nemo-terminal-theme "Catppuccin Mocha" # apply a theme by its Gogh name +# nemo-terminal-theme --list [FILTER] # list available Gogh theme names +# +# Only colors are changed; font, cursor shape and scrollback are left as-is. + +set -euo pipefail + +SCHEMA="org.nemo.extensions.nemo-terminal" +RAW_BASE="https://raw.githubusercontent.com/Gogh-Co/Gogh/master/themes" +API_THEMES="https://api.github.com/repos/Gogh-Co/Gogh/contents/themes" + +die() { echo "nemo-terminal-theme: $*" >&2; exit 1; } + +command -v curl >/dev/null || die "curl is required" + +# Resolve a gsettings whose GIO has the dconf backend. A non-system gsettings +# (e.g. one earlier on PATH from Homebrew or conda) may be built without the +# dconf settings backend and then silently writes to an in-memory backend — +# `set` succeeds but nothing persists to dconf. Prefer the system binary. +if [ -x /usr/bin/gsettings ]; then + GSETTINGS=/usr/bin/gsettings +else + GSETTINGS="$(command -v gsettings || true)" +fi + +urlencode() { # percent-encode anything outside the RFC3986 unreserved set + local s="$1" out="" c i + for (( i=0; i<${#s}; i++ )); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9._~-]) out+="$c" ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + printf '%s' "$out" +} + +list_themes() { + local filter="${1:-}" + curl -fsSL "$API_THEMES" \ + | grep -oE '"name": "[^"]+\.yml"' \ + | sed -E 's/"name": "(.*)\.yml"/\1/' \ + | { if [ -n "$filter" ]; then grep -i -- "$filter"; else cat; fi; } +} + +if [ "${1:-}" = "--list" ] || [ "${1:-}" = "-l" ]; then + list_themes "${2:-}" + exit 0 +fi +[ $# -ge 1 ] || die 'usage: nemo-terminal-theme "Theme Name" | --list [FILTER]' + +# Applying needs gsettings and the schema (with the appearance keys) installed. +[ -n "$GSETTINGS" ] || die "gsettings is required to apply a theme" +"$GSETTINGS" list-keys "$SCHEMA" >/dev/null 2>&1 \ + || die "schema '$SCHEMA' not found — install the nemo-terminal gschema first." +"$GSETTINGS" list-keys "$SCHEMA" | grep -qx "terminal-palette" \ + || die "schema '$SCHEMA' has no 'terminal-palette' key — update and recompile the gschema first." + +theme="$*" +url="$RAW_BASE/$(urlencode "$theme").yml" +yml="$(curl -fsSL "$url")" \ + || die "could not fetch theme '$theme'. Run with --list to see available names." + +field() { # extract the value of a 'key: ' line, ignoring quotes/comments + printf '%s\n' "$yml" \ + | sed -n -E "s/^$1:[[:space:]]*'?([^'[:space:]]+).*/\1/p" | head -n1 +} + +# Build the 16-colour palette as a GVariant array literal. +palette="[" +for i in $(seq -w 1 16); do + c="$(field "color_$i")" + [ -n "$c" ] || die "theme '$theme' is missing color_$i" + palette+="'$c'," +done +palette="${palette%,}]" + +bg="$(field background)"; fg="$(field foreground)"; cur="$(field cursor)" +[ -n "$bg" ] && [ -n "$fg" ] || die "theme '$theme' is missing background/foreground" + +"$GSETTINGS" set "$SCHEMA" terminal-palette "$palette" +"$GSETTINGS" set "$SCHEMA" terminal-background-color "$bg" +"$GSETTINGS" set "$SCHEMA" terminal-foreground-color "$fg" +[ -n "$cur" ] && "$GSETTINGS" set "$SCHEMA" terminal-cursor-color "$cur" + +echo "Applied Gogh theme '$theme' to nemo-terminal." +echo " background=$bg foreground=$fg cursor=${cur:-}"