Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nemo-terminal/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
173 changes: 172 additions & 1 deletion nemo-terminal/src/nemo-terminal-prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
115 changes: 100 additions & 15 deletions nemo-terminal/src/nemo_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
Loading