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:-}"