From 5a2a15a234bdcd963aaed14bde45505373edfb66 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 17:59:10 -0300 Subject: [PATCH 01/15] feat: update default hotkey to Ctrl+Print; enhance icon handling and installation scripts --- README.md | 13 ++-- .../io.github.rafa.ScreenuxScreenshot.png | Bin 0 -> 1879 bytes .../io.github.rafa.ScreenuxScreenshot.json | 1 + packaging/linux/screenux-screenshot.desktop | 2 +- packaging/linux/screenux-screenshot.png | Bin 666 -> 1879 bytes scripts/build_deb.sh | 14 +++- scripts/install/install-screenux.sh | 1 + scripts/install/lib/common.sh | 23 ++++--- scripts/install/uninstall-screenux.sh | 1 + src/screenux_hotkey.py | 7 +- src/screenux_screenshot.py | 16 +---- src/screenux_window.py | 17 ++++- tests/test_deb_packaging_files.py | 3 +- tests/test_hotkey_backend_gnome.py | 12 ++-- tests/test_hotkey_config.py | 6 +- tests/test_hotkey_fallback.py | 19 +++--- tests/test_install_uninstall_scripts.py | 12 +++- tests/test_packaging_icon_metadata.py | 2 + tests/test_theme_icon_selection.py | 38 +---------- tests/test_window_and_screenshot.py | 64 ++++++++++++++++++ 20 files changed, 157 insertions(+), 94 deletions(-) create mode 100644 assets/icons/io.github.rafa.ScreenuxScreenshot.png diff --git a/README.md b/README.md index f220424..f85cefb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate ## 🧩 Features - Capture with `Take Screenshot` -- Default global hotkey: `Ctrl+Shift+S` +- Default global hotkey: `Ctrl+Print` - Status updates: `Ready`, `Capturing...`, `Saved: `, `Cancelled`, `Failed: ` - Built-in editor for quick annotations (shapes/text) - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) @@ -63,7 +63,7 @@ Expected after install: - CLI available at `/usr/bin/screenux-screenshot` - Desktop launcher visible in app menu -- Icon installed at `/usr/share/icons/hicolor/256x256/apps/screenux-screenshot.png` +- Icon installed at `/usr/share/icons/hicolor/256x256/apps/io.github.rafa.ScreenuxScreenshot.png` (PNG primary) Remove later (optional): @@ -77,7 +77,7 @@ sudo apt remove -y screenux-screenshot ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak ``` -The installer creates a desktop entry and installs app icons at `~/.local/share/icons/hicolor/scalable/apps/` so launcher/taskbar icon lookup works reliably. It includes theme variants (`io.github.rafa.ScreenuxScreenshot-light.svg` and `io.github.rafa.ScreenuxScreenshot-dark.svg`) and refreshes the local icon cache when GTK cache tools are available. +The installer creates a desktop entry and installs app icons so launcher/taskbar icon lookup works reliably: PNG primary at `~/.local/share/icons/hicolor/256x256/apps/io.github.rafa.ScreenuxScreenshot.png` plus complementary SVG assets in `~/.local/share/icons/hicolor/scalable/apps/` (including `io.github.rafa.ScreenuxScreenshot-light.svg` and `io.github.rafa.ScreenuxScreenshot-dark.svg`). It refreshes the local icon cache when GTK cache tools are available. Optional GNOME Print Screen shortcut: @@ -132,12 +132,13 @@ Save folder behavior: Global hotkey behavior: -- Default shortcut is `Ctrl+Shift+S`. -- If it is already taken, Screenux falls back to `Ctrl+Alt+S` (then `Alt+Shift+S`, then `Super+Shift+S`). +- Default shortcut is `Ctrl+Print`. +- If it is already taken, Screenux falls back to `Ctrl+Shift+S` (then `Ctrl+Alt+S`, then `Alt+Shift+S`, then `Super+Shift+S`). - On GNOME, the shortcut is persisted as a GNOME custom shortcut and works when the app is closed. - On non-GNOME desktops, global shortcut support is best-effort while the app is running. - Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). -- You can change or disable the shortcut from the app window (`Apply` / `Disable`). +- You can type a new shortcut directly in the app window and press `Enter` or `Apply`. +- You can return to default quickly with `Default`, or disable with `Disable`. ## 🖼️ UI example diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot.png b/assets/icons/io.github.rafa.ScreenuxScreenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..afdb84e53f2ff9f21dd1020cd4863e3b5bf3c61b GIT binary patch literal 1879 zcmaJ?dpOitA3wjJ(aebDO-hvU!-z5pVQ0l^&HR>q;aevCCF~&}IpPu)R_C5cc^F8N#JD>0Oe9rm(`u;A;$ftdIx67VZU45NitD&_2+~^V2i*dXgxO~OZ*Gs#q(=?fa@()I{ z7Yf8KU!46-rTaZ2eZv|Hboto*Lwj`Z+LPSd`9EkRTt^qpirs1rgQEy~`JN9@O+-FX z$Pec1l~Th+CG?C`f8Q@XRNOp3cD|ZdWwY^>kYUx5+Royihw!I}H zMK->8;N?xD7x{^pRTXzGMYs$v!A0AYTIg%zb)kUfXIXcsGuB)m2oG&(Bt*r{a? zP2it*3nJJq^WwAO2s?l!QEpL=W5A?pQ%EwQS@mpmtu{dE8-O-Ngm_f382uJEKD3+> z&L_ef<#KuCyc0bc)LkxSHqY8)69oFQ$~&DcT=&7&6GsSAyLddZ%aTJpXXm>EGe05# z+bp@^^9w~spz249Rn|Kzs@n@?PmG24_L659=2*@J0qVJ2{BnWo3}{eFmkg3C-yU9)DMYoUasA8ViPug7`$FgR zLiz- zmtqb(fNyuUnP&SLh}+MIkR$-wv;oU{Y2hf78G-}Yz|Bize#A)?P)7kd1pIsum$?pT zQ2>bmfhH6{X@5qTMwykqQ!!~w_PGE|Zm>IWw)Xpu5c1s+n3}yplSUp2fHR#{7PF?r z6mTV2kx|jQ8TN;taPp!yd3*p3bI0eyoXg}r8M+=3y zb9E?_o9uTuw{O~XFbFwkZYr>$MGH9I%+=|*SiAk-Gy8P%^o{W6O1t1Uwz9)0QqpO^ zmPkW&85^r5dEpddm)@I?<^9UCtX`O32!ey9Vpezu$phvUG-Mydyf*@dnIJf5nt#`n zw-I{&Tj|JpI1U3a_|^S@_w=cqJoKkP|BIA+3Ww4CrpfM;^3hlHlk(~BfF(bgeL~^4 z3a7?o)b&&I(@wy0ZX&2tZujGkn80(zaiYR{J;82m65{j={oMi!#zgi#<>+5hfwY{j zuqEq2JocXq5P$ez1e2|k-KSRZAta$A{)X)A%^aNr7ul)?X!DWho42$hdIAH8@Vvbp z7k)DSZP1u5=iq&mE+vx5ifdV|J4Ip^y;>*AjUMJ519lzO`o*q+UV%+2F$2p(Ug4eX zrd%n&u}?Oj=sYg-Mv=(bXFQef>;Lr2N3ewF+XkCk6?w`a6>%V@I2W7|po)ABfP zVmw57Cr6afC@_cb_*L2jYhjk#y9btd?%^q0I14PUeZsd|iuR>|hsSabIE2KC8jVv{ zdPj1R-Q)7Y+U5kXV6D4-Fe${m>b_2KJU zINVshi+`dc?IFVo>Km=gJVW*^w^}xXB~WsTdEy|7@rM8+h(;ANOn|X61{7%KbxI=v zB2^!U{M8U*&|LE?ED0m*vyZ@nreZW=iX7#Sb7MhhuiO#YwEb1wc>qRCw1Lh`t-ZIX z)5l={Ua6Vd#4T!Wh8hx9USmBI+9|DPfKZof9ZlRKGDQ+>eRkrQ*ihdz6 zP*U&&+O|eokNW-K{aH;{576pGr&uf5Is=P@`LH~HzemkA*8#ovx`xtOH8O^@Iq!QW zvfV30o-m9@0?W;98Y&p)M4Xue#n4_KrHku%;9^vdde@7h0q@O^O*_Epv6qYJ+h2@a zHIy8F@U?<0O0aR#ji{=0=T%9bNjK(fWBM;!KKGdc akU7MS|FE4H0Idu7sNmwb&!KXUfBauz@gqzC literal 0 HcmV?d00001 diff --git a/flatpak/io.github.rafa.ScreenuxScreenshot.json b/flatpak/io.github.rafa.ScreenuxScreenshot.json index 4588aff..5aee09c 100644 --- a/flatpak/io.github.rafa.ScreenuxScreenshot.json +++ b/flatpak/io.github.rafa.ScreenuxScreenshot.json @@ -21,6 +21,7 @@ "install -Dm755 src/screenux_editor.py /app/share/screenux/screenux_editor.py", "install -Dm755 src/screenux_window.py /app/share/screenux/screenux_window.py", "install -Dm644 io.github.rafa.ScreenuxScreenshot.desktop /app/share/applications/io.github.rafa.ScreenuxScreenshot.desktop", + "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot.png /app/share/icons/hicolor/256x256/apps/io.github.rafa.ScreenuxScreenshot.png", "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg", "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot-light.svg", "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot-dark.svg" diff --git a/packaging/linux/screenux-screenshot.desktop b/packaging/linux/screenux-screenshot.desktop index 1bf7304..a1726e7 100644 --- a/packaging/linux/screenux-screenshot.desktop +++ b/packaging/linux/screenux-screenshot.desktop @@ -2,6 +2,6 @@ Type=Application Name=Screenux Screenshot Exec=screenux-screenshot -Icon=screenux-screenshot +Icon=io.github.rafa.ScreenuxScreenshot Terminal=false Categories=Utility;Graphics; diff --git a/packaging/linux/screenux-screenshot.png b/packaging/linux/screenux-screenshot.png index a6122f4899b3b17d18e3116b451bd1bf4693a9ed..afdb84e53f2ff9f21dd1020cd4863e3b5bf3c61b 100644 GIT binary patch literal 1879 zcmaJ?dpOitA3wjJ(aebDO-hvU!-z5pVQ0l^&HR>q;aevCCF~&}IpPu)R_C5cc^F8N#JD>0Oe9rm(`u;A;$ftdIx67VZU45NitD&_2+~^V2i*dXgxO~OZ*Gs#q(=?fa@()I{ z7Yf8KU!46-rTaZ2eZv|Hboto*Lwj`Z+LPSd`9EkRTt^qpirs1rgQEy~`JN9@O+-FX z$Pec1l~Th+CG?C`f8Q@XRNOp3cD|ZdWwY^>kYUx5+Royihw!I}H zMK->8;N?xD7x{^pRTXzGMYs$v!A0AYTIg%zb)kUfXIXcsGuB)m2oG&(Bt*r{a? zP2it*3nJJq^WwAO2s?l!QEpL=W5A?pQ%EwQS@mpmtu{dE8-O-Ngm_f382uJEKD3+> z&L_ef<#KuCyc0bc)LkxSHqY8)69oFQ$~&DcT=&7&6GsSAyLddZ%aTJpXXm>EGe05# z+bp@^^9w~spz249Rn|Kzs@n@?PmG24_L659=2*@J0qVJ2{BnWo3}{eFmkg3C-yU9)DMYoUasA8ViPug7`$FgR zLiz- zmtqb(fNyuUnP&SLh}+MIkR$-wv;oU{Y2hf78G-}Yz|Bize#A)?P)7kd1pIsum$?pT zQ2>bmfhH6{X@5qTMwykqQ!!~w_PGE|Zm>IWw)Xpu5c1s+n3}yplSUp2fHR#{7PF?r z6mTV2kx|jQ8TN;taPp!yd3*p3bI0eyoXg}r8M+=3y zb9E?_o9uTuw{O~XFbFwkZYr>$MGH9I%+=|*SiAk-Gy8P%^o{W6O1t1Uwz9)0QqpO^ zmPkW&85^r5dEpddm)@I?<^9UCtX`O32!ey9Vpezu$phvUG-Mydyf*@dnIJf5nt#`n zw-I{&Tj|JpI1U3a_|^S@_w=cqJoKkP|BIA+3Ww4CrpfM;^3hlHlk(~BfF(bgeL~^4 z3a7?o)b&&I(@wy0ZX&2tZujGkn80(zaiYR{J;82m65{j={oMi!#zgi#<>+5hfwY{j zuqEq2JocXq5P$ez1e2|k-KSRZAta$A{)X)A%^aNr7ul)?X!DWho42$hdIAH8@Vvbp z7k)DSZP1u5=iq&mE+vx5ifdV|J4Ip^y;>*AjUMJ519lzO`o*q+UV%+2F$2p(Ug4eX zrd%n&u}?Oj=sYg-Mv=(bXFQef>;Lr2N3ewF+XkCk6?w`a6>%V@I2W7|po)ABfP zVmw57Cr6afC@_cb_*L2jYhjk#y9btd?%^q0I14PUeZsd|iuR>|hsSabIE2KC8jVv{ zdPj1R-Q)7Y+U5kXV6D4-Fe${m>b_2KJU zINVshi+`dc?IFVo>Km=gJVW*^w^}xXB~WsTdEy|7@rM8+h(;ANOn|X61{7%KbxI=v zB2^!U{M8U*&|LE?ED0m*vyZ@nreZW=iX7#Sb7MhhuiO#YwEb1wc>qRCw1Lh`t-ZIX z)5l={Ua6Vd#4T!Wh8hx9USmBI+9|DPfKZof9ZlRKGDQ+>eRkrQ*ihdz6 zP*U&&+O|eokNW-K{aH;{576pGr&uf5Is=P@`LH~HzemkA*8#ovx`xtOH8O^@Iq!QW zvfV30o-m9@0?W;98Y&p)M4Xue#n4_KrHku%;9^vdde@7h0q@O^O*_Epv6qYJ+h2@a zHIy8F@U?<0O0aR#ji{=0=T%9bNjK(fWBM;!KKGdc akU7MS|FE4H0Idu7sNmwb&!KXUfBauz@gqzC literal 666 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEyL{AsTkcwMxuNVU5SQs2L zp9qDRb7eee)BbSSJ&gZDa0de$g9JkYLjxnV7!8a=#Y_%H>ieBqp$IpJdJ#hlBd4=^ Y Installing app icon: ${ICON_FILE}" - mkdir -p "${ICON_DIR}" + mkdir -p "${ICON_PNG_DIR}" "${ICON_SVG_DIR}" cp -f -- "${APP_ICON_SOURCE}" "${ICON_FILE}" + cp -f -- "${APP_ICON_SVG_SOURCE}" "${ICON_FILE_SVG}" cp -f -- "${APP_ICON_LIGHT_SOURCE}" "${ICON_FILE_LIGHT}" cp -f -- "${APP_ICON_DARK_SOURCE}" "${ICON_FILE_DARK}" } @@ -85,9 +90,9 @@ remove_desktop_entry() { } remove_icon_asset() { - if [[ -e "${ICON_FILE}" || -L "${ICON_FILE}" || -e "${ICON_FILE_LIGHT}" || -L "${ICON_FILE_LIGHT}" || -e "${ICON_FILE_DARK}" || -L "${ICON_FILE_DARK}" ]]; then - echo "==> Removing app icon files from: ${ICON_DIR}" - rm -f -- "${ICON_FILE}" "${ICON_FILE_LIGHT}" "${ICON_FILE_DARK}" + if [[ -e "${ICON_FILE}" || -L "${ICON_FILE}" || -e "${ICON_FILE_SVG}" || -L "${ICON_FILE_SVG}" || -e "${ICON_FILE_LIGHT}" || -L "${ICON_FILE_LIGHT}" || -e "${ICON_FILE_DARK}" || -L "${ICON_FILE_DARK}" ]]; then + echo "==> Removing app icon files" + rm -f -- "${ICON_FILE}" "${ICON_FILE_SVG}" "${ICON_FILE_LIGHT}" "${ICON_FILE_DARK}" fi } diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh index 8eb1b67..c4f3095 100755 --- a/scripts/install/uninstall-screenux.sh +++ b/scripts/install/uninstall-screenux.sh @@ -56,6 +56,7 @@ validate_uninstall() { [[ ! -e "${WRAPPER_PATH}" && ! -L "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper still exists at ${WRAPPER_PATH}" [[ ! -e "${DESKTOP_FILE}" && ! -L "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry still exists at ${DESKTOP_FILE}" [[ ! -e "${ICON_FILE}" && ! -L "${ICON_FILE}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE}" + [[ ! -e "${ICON_FILE_SVG}" && ! -L "${ICON_FILE_SVG}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE_SVG}" [[ ! -e "${ICON_FILE_LIGHT}" && ! -L "${ICON_FILE_LIGHT}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE_LIGHT}" [[ ! -e "${ICON_FILE_DARK}" && ! -L "${ICON_FILE_DARK}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE_DARK}" diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index a19c2ef..e4a1ab9 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -7,8 +7,8 @@ from types import SimpleNamespace from typing import Callable -DEFAULT_SHORTCUT = "Ctrl+Shift+S" -FALLBACK_SHORTCUTS = ("Ctrl+Alt+S", "Alt+Shift+S", "Super+Shift+S") +DEFAULT_SHORTCUT = "Ctrl+Print" +FALLBACK_SHORTCUTS = ("Ctrl+Shift+S", "Ctrl+Alt+S", "Alt+Shift+S", "Super+Shift+S") HOTKEY_CONFIG_KEY = "global_hotkey" GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" @@ -84,6 +84,9 @@ def _normalize_key_token(token: str) -> str: upper = text.upper() if upper == "PRINT": return "Print" + compact = re.sub(r"[\s_-]+", "", upper) + if compact in {"PRINTSCREEN", "PRTSC", "PRTSCN"}: + return "Print" if upper.startswith("F") and upper[1:].isdigit(): return upper if upper in ("SPACE", "TAB", "ESC", "ESCAPE", "ENTER"): diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 9e204d4..7c111f3 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -27,8 +27,6 @@ Gtk = None # type: ignore[assignment] # pragma: no cover APP_ID = "io.github.rafa.ScreenuxScreenshot" -LIGHT_ICON_NAME = f"{APP_ID}-light" -DARK_ICON_NAME = f"{APP_ID}-dark" _MAX_CONFIG_SIZE = 64 * 1024 _ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} APP_VERSION = "0.1.0" @@ -49,20 +47,8 @@ def _print_help() -> None: ) -def _prefers_dark_theme() -> bool: - if Gtk is None: - return False - settings = Gtk.Settings.get_default() - if settings is None: - return False - try: - return bool(settings.get_property("gtk-application-prefer-dark-theme")) - except Exception: - return False - - def select_icon_name() -> str: - return DARK_ICON_NAME if _prefers_dark_theme() else LIGHT_ICON_NAME + return APP_ID def enforce_offline_mode() -> None: diff --git a/src/screenux_window.py b/src/screenux_window.py index 9a0ecae..3260e15 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -12,6 +12,7 @@ from gi.repository import Gio, GLib, Gtk, Pango from screenux_editor import AnnotationEditor, load_image_surface +from screenux_hotkey import DEFAULT_SHORTCUT PORTAL_DEST = "org.freedesktop.portal.Desktop" PORTAL_PATH = "/org/freedesktop/portal/desktop" @@ -144,13 +145,18 @@ def _build_hotkey_settings(self) -> None: edit_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self._hotkey_entry = Gtk.Entry() self._hotkey_entry.set_hexpand(True) - self._hotkey_entry.set_placeholder_text("Ctrl+Shift+S") + self._hotkey_entry.set_placeholder_text(DEFAULT_SHORTCUT) + self._hotkey_entry.connect("activate", self._on_hotkey_entry_activate) edit_row.append(self._hotkey_entry) apply_btn = Gtk.Button(label="Apply") apply_btn.connect("clicked", self._on_hotkey_apply) edit_row.append(apply_btn) + default_btn = Gtk.Button(label="Default") + default_btn.connect("clicked", self._on_hotkey_restore_default) + edit_row.append(default_btn) + disable_btn = Gtk.Button(label="Disable") disable_btn.connect("clicked", self._on_hotkey_disable) edit_row.append(disable_btn) @@ -191,6 +197,15 @@ def _on_hotkey_apply(self, _button: Gtk.Button) -> None: return self._apply_hotkey_result(result) + def _on_hotkey_entry_activate(self, _entry: Gtk.Entry) -> None: + self._on_hotkey_apply(_entry) + + def _on_hotkey_restore_default(self, _button: Gtk.Button) -> None: + if self._hotkey_manager is None: + return + result = self._hotkey_manager.apply_shortcut(DEFAULT_SHORTCUT) + self._apply_hotkey_result(result) + def _on_hotkey_disable(self, _button: Gtk.Button) -> None: if self._hotkey_manager is None: return diff --git a/tests/test_deb_packaging_files.py b/tests/test_deb_packaging_files.py index 3e17712..d0f082e 100644 --- a/tests/test_deb_packaging_files.py +++ b/tests/test_deb_packaging_files.py @@ -10,6 +10,7 @@ HOOKS_DIR = ROOT / "packaging" / "pyinstaller_hooks" GTK_HOOK = HOOKS_DIR / "hook-gi.repository.Gtk.py" GDK_HOOK = HOOKS_DIR / "hook-gi.repository.Gdk.py" +APP_ID = "io.github.rafa.ScreenuxScreenshot" def _write_executable(path: Path, content: str) -> None: @@ -21,7 +22,7 @@ class DebianPackagingTests(unittest.TestCase): def test_desktop_entry_exists_and_declares_expected_fields(self): content = DESKTOP_FILE.read_text(encoding="utf-8") self.assertIn("Exec=screenux-screenshot", content) - self.assertIn("Icon=screenux-screenshot", content) + self.assertIn(f"Icon={APP_ID}", content) self.assertIn("Type=Application", content) self.assertIn("Categories=Utility;Graphics;", content) diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index e79a31e..54f274e 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -41,7 +41,7 @@ def test_collect_gnome_taken_shortcuts_parses_custom_and_native_bindings(): ), ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{custom_path}", "binding"): ( 0, - "['s']\n", + "['Print']\n", "", ), ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "['Print']\n", ""), @@ -55,7 +55,7 @@ def test_collect_gnome_taken_shortcuts_parses_custom_and_native_bindings(): taken = hotkey.collect_gnome_taken_shortcuts(runner=runner) - assert "Ctrl+Shift+S" in taken + assert "Ctrl+Print" in taken assert "Print" in taken @@ -94,7 +94,7 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting ), ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "binding"): ( 0, - "['s']\n", + "['Print']\n", "", ), ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), @@ -106,9 +106,9 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting } runner = _make_runner(mapping, calls) - result = hotkey.register_gnome_shortcut("Ctrl+Shift+S", runner=runner) + result = hotkey.register_gnome_shortcut("Ctrl+Print", runner=runner) - assert result.shortcut == "Ctrl+Alt+S" + assert result.shortcut == "Ctrl+Shift+S" assert result.warning is not None assert any( command @@ -128,7 +128,7 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting "set", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", "binding", - "['s']", + "['s']", ] for command in calls ) diff --git a/tests/test_hotkey_config.py b/tests/test_hotkey_config.py index a2c544a..aac15ec 100644 --- a/tests/test_hotkey_config.py +++ b/tests/test_hotkey_config.py @@ -13,9 +13,9 @@ def test_read_hotkey_uses_default_when_unset(): def test_write_and_read_hotkey_round_trip(): config = {} - hotkey.write_hotkey_to_config(config, "ctrl+shift+s") - assert config["global_hotkey"] == "Ctrl+Shift+S" - assert hotkey.read_hotkey_from_config(config) == "Ctrl+Shift+S" + hotkey.write_hotkey_to_config(config, "ctrl+print screen") + assert config["global_hotkey"] == "Ctrl+Print" + assert hotkey.read_hotkey_from_config(config) == "Ctrl+Print" def test_write_and_read_disabled_hotkey(): diff --git a/tests/test_hotkey_fallback.py b/tests/test_hotkey_fallback.py index 37ba603..9dfd08e 100644 --- a/tests/test_hotkey_fallback.py +++ b/tests/test_hotkey_fallback.py @@ -8,36 +8,37 @@ def test_resolve_shortcut_uses_first_fallback_when_default_is_taken(): - taken = {"Ctrl+Shift+S"} + taken = {"Ctrl+Print"} shortcut, warning = hotkey.resolve_shortcut_with_fallback( - "Ctrl+Shift+S", lambda value: value in taken + "Ctrl+Print", lambda value: value in taken ) - assert shortcut == "Ctrl+Alt+S" + assert shortcut == "Ctrl+Shift+S" assert warning is not None - assert "Ctrl+Shift+S" in warning + assert "Ctrl+Print" in warning def test_resolve_shortcut_skips_taken_first_fallback(): - taken = {"Ctrl+Shift+S", "Ctrl+Alt+S"} + taken = {"Ctrl+Print", "Ctrl+Shift+S"} shortcut, warning = hotkey.resolve_shortcut_with_fallback( - "Ctrl+Shift+S", lambda value: value in taken + "Ctrl+Print", lambda value: value in taken ) - assert shortcut == "Alt+Shift+S" + assert shortcut == "Ctrl+Alt+S" assert warning is not None - assert "Alt+Shift+S" in warning + assert "Ctrl+Alt+S" in warning def test_resolve_shortcut_disables_when_all_candidates_are_taken(): taken = { + "Ctrl+Print", "Ctrl+Shift+S", "Ctrl+Alt+S", "Alt+Shift+S", "Super+Shift+S", } shortcut, warning = hotkey.resolve_shortcut_with_fallback( - "Ctrl+Shift+S", lambda value: value in taken + "Ctrl+Print", lambda value: value in taken ) assert shortcut is None diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py index eea37ce..46950c0 100644 --- a/tests/test_install_uninstall_scripts.py +++ b/tests/test_install_uninstall_scripts.py @@ -172,7 +172,8 @@ def test_installer_installs_bundle_and_creates_local_entries(self): wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" - icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" + icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/256x256/apps/{APP_ID}.png" + icon_file_svg = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" icon_file_light = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg" icon_file_dark = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg" @@ -186,6 +187,7 @@ def test_installer_installs_bundle_and_creates_local_entries(self): desktop_file.read_text(encoding="utf-8"), ) self.assertTrue(icon_file.exists()) + self.assertTrue(icon_file_svg.exists()) self.assertTrue(icon_file_light.exists()) self.assertTrue(icon_file_dark.exists()) @@ -231,7 +233,8 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): ) wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" - icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" + icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/256x256/apps/{APP_ID}.png" + icon_file_svg = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" icon_file_light = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg" icon_file_dark = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg" data_dir = Path(env["HOME"]) / f".var/app/{APP_ID}" @@ -242,7 +245,9 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): desktop_file.parent.mkdir(parents=True, exist_ok=True) desktop_file.write_text("[Desktop Entry]\n", encoding="utf-8") icon_file.parent.mkdir(parents=True, exist_ok=True) - icon_file.write_text("", encoding="utf-8") + icon_file.write_bytes(b"png") + icon_file_svg.parent.mkdir(parents=True, exist_ok=True) + icon_file_svg.write_text("", encoding="utf-8") icon_file_light.write_text("", encoding="utf-8") icon_file_dark.write_text("", encoding="utf-8") @@ -254,6 +259,7 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): self.assertFalse(wrapper_path.exists()) self.assertFalse(desktop_file.exists()) self.assertFalse(icon_file.exists()) + self.assertFalse(icon_file_svg.exists()) self.assertFalse(icon_file_light.exists()) self.assertFalse(icon_file_dark.exists()) self.assertFalse(data_dir.exists()) diff --git a/tests/test_packaging_icon_metadata.py b/tests/test_packaging_icon_metadata.py index 6cfa577..1554e2e 100644 --- a/tests/test_packaging_icon_metadata.py +++ b/tests/test_packaging_icon_metadata.py @@ -19,6 +19,7 @@ def test_flatpak_manifest_installs_app_icon_asset(self) -> None: build_commands = manifest["modules"][0]["build-commands"] expected_targets = ( + f"/app/share/icons/hicolor/256x256/apps/{APP_ID}.png", f"/app/share/icons/hicolor/scalable/apps/{APP_ID}.svg", f"/app/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg", f"/app/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg", @@ -27,6 +28,7 @@ def test_flatpak_manifest_installs_app_icon_asset(self) -> None: self.assertTrue(any(command.endswith(f" {target}") for command in build_commands)) icon_files = ( + ROOT / "assets" / "icons" / f"{APP_ID}.png", ROOT / "assets" / "icons" / f"{APP_ID}.svg", ROOT / "assets" / "icons" / f"{APP_ID}-light.svg", ROOT / "assets" / "icons" / f"{APP_ID}-dark.svg", diff --git a/tests/test_theme_icon_selection.py b/tests/test_theme_icon_selection.py index 50d0308..4a613fc 100644 --- a/tests/test_theme_icon_selection.py +++ b/tests/test_theme_icon_selection.py @@ -7,43 +7,9 @@ import src.screenux_screenshot as screenshot -class _FakeSettings: - def __init__(self, prefer_dark: bool): - self._prefer_dark = prefer_dark - - def get_property(self, name: str): - if name == "gtk-application-prefer-dark-theme": - return self._prefer_dark - return None - - -class _FakeGtk: - class Settings: - _settings = None - - @staticmethod - def get_default(): - return _FakeGtk.Settings._settings - - class ThemeIconSelectionTests(unittest.TestCase): - def test_select_icon_name_uses_dark_icon_when_dark_theme_is_preferred(self): - original_gtk = screenshot.Gtk - try: - _FakeGtk.Settings._settings = _FakeSettings(prefer_dark=True) - screenshot.Gtk = _FakeGtk - self.assertEqual(screenshot.select_icon_name(), f"{screenshot.APP_ID}-dark") - finally: - screenshot.Gtk = original_gtk - - def test_select_icon_name_uses_light_icon_when_dark_theme_is_not_preferred(self): - original_gtk = screenshot.Gtk - try: - _FakeGtk.Settings._settings = _FakeSettings(prefer_dark=False) - screenshot.Gtk = _FakeGtk - self.assertEqual(screenshot.select_icon_name(), f"{screenshot.APP_ID}-light") - finally: - screenshot.Gtk = original_gtk + def test_select_icon_name_returns_app_id(self): + self.assertEqual(screenshot.select_icon_name(), screenshot.APP_ID) if __name__ == "__main__": diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 4047db3..2fc3e3a 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -25,6 +25,26 @@ def set_sensitive(self, value): self.sensitive = value +class DummyEntry: + def __init__(self, text=""): + self.text = text + + def get_text(self): + return self.text + + def set_text(self, text): + self.text = text + + +class DummyHotkeyManager: + def __init__(self): + self.last_applied = None + + def apply_shortcut(self, value): + self.last_applied = value + return SimpleNamespace(shortcut=value, warning=None) + + class DummyBus: def __init__(self, unique_name=":1.23"): self.unique_name = unique_name @@ -103,6 +123,50 @@ def test_window_take_screenshot_public_method(): assert called == [self._button] +def test_window_hotkey_apply_accepts_printscreen_alias(): + manager = DummyHotkeyManager() + self = SimpleNamespace( + _hotkey_manager=manager, + _hotkey_entry=DummyEntry("ctrl+print screen"), + _set_status=lambda text: setattr(self, "_status_text", text), + _apply_hotkey_result=lambda result: setattr(self, "_applied_result", result), + ) + self._status_text = "" + self._applied_result = None + + window.MainWindow._on_hotkey_apply(self, None) + + assert manager.last_applied == "ctrl+print screen" + assert self._applied_result is not None + assert self._applied_result.shortcut == "ctrl+print screen" + + +def test_window_hotkey_restore_default(): + manager = DummyHotkeyManager() + self = SimpleNamespace( + _hotkey_manager=manager, + _apply_hotkey_result=lambda result: setattr(self, "_applied_result", result), + ) + self._applied_result = None + + window.MainWindow._on_hotkey_restore_default(self, None) + + assert manager.last_applied == "Ctrl+Print" + assert self._applied_result is not None + assert self._applied_result.shortcut == "Ctrl+Print" + + +def test_window_hotkey_entry_activate_triggers_apply(): + calls = [] + self = SimpleNamespace( + _on_hotkey_apply=lambda button: calls.append(button), + ) + + window.MainWindow._on_hotkey_entry_activate(self, "entry") + + assert calls == ["entry"] + + class DummyError(Exception): def __init__(self, message): self.message = message From 2a87ed1e899b4716be3d1613e0c6ac723bc9ffba Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:06:32 -0300 Subject: [PATCH 02/15] feat: enhance hotkey functionality and improve capture flow; update README for clarity --- README.md | 7 +- src/screenux_screenshot.py | 8 +- src/screenux_window.py | 112 ++++++++++++++++- tests/test_window_and_screenshot.py | 188 ++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f85cefb..14f1030 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Optional GNOME Print Screen shortcut: ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak --print-screen ``` -This maps `Print` to `screenux-screenshot --capture`, which opens Screenux and immediately starts the capture flow. +This maps `Print` to `screenux-screenshot --capture`, which opens Screenux directly in the final capture/edit stage (without stopping on the main screen first). If Screenux is already installed for your user, you can rerun: @@ -137,8 +137,9 @@ Global hotkey behavior: - On GNOME, the shortcut is persisted as a GNOME custom shortcut and works when the app is closed. - On non-GNOME desktops, global shortcut support is best-effort while the app is running. - Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). -- You can type a new shortcut directly in the app window and press `Enter` or `Apply`. -- You can return to default quickly with `Default`, or disable with `Disable`. +- While the shortcut field is focused, press the key combo and Screenux builds it automatically (example: `Ctrl + S`). +- You can apply with `Enter` or `Apply`. +- You can return to default with `Default`, or clear/disable with `Clear`. ## 🖼️ UI example diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 7c111f3..5eaa1f7 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -209,6 +209,8 @@ def do_activate(self) -> None: icon_name = select_icon_name() Gtk.Window.set_default_icon_name(icon_name) hotkey_result = self._hotkey_manager.ensure_registered() + auto_capture = self._auto_capture_pending + self._auto_capture_pending = False window = self.props.active_window if window is None: window = MainWindow( @@ -226,9 +228,11 @@ def do_activate(self) -> None: window.set_icon_name(icon_name) if hotkey_result.warning and hasattr(window, "set_nonblocking_warning"): window.set_nonblocking_warning(hotkey_result.warning) + if auto_capture and hasattr(window, "trigger_shortcut_capture"): + window.trigger_shortcut_capture() + return window.present() - if self._auto_capture_pending: - self._auto_capture_pending = False + if auto_capture: GLib.idle_add(self._trigger_auto_capture, window) else: class ScreenuxScreenshotApp: # pragma: no cover diff --git a/src/screenux_window.py b/src/screenux_window.py index 3260e15..f1dc1f3 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -8,11 +8,12 @@ import gi +gi.require_version("Gdk", "4.0") gi.require_version("Gtk", "4.0") -from gi.repository import Gio, GLib, Gtk, Pango +from gi.repository import Gdk, Gio, GLib, Gtk, Pango from screenux_editor import AnnotationEditor, load_image_surface -from screenux_hotkey import DEFAULT_SHORTCUT +from screenux_hotkey import DEFAULT_SHORTCUT, normalize_shortcut PORTAL_DEST = "org.freedesktop.portal.Desktop" PORTAL_PATH = "/org/freedesktop/portal/desktop" @@ -20,6 +21,63 @@ PORTAL_REQUEST_IFACE = "org.freedesktop.portal.Request" +def _shortcut_display_text(shortcut: str) -> str: + return shortcut.replace("+", " + ") + + +def _shortcut_modifiers_from_state(state: int) -> list[str]: + modifiers: list[str] = [] + mapping = ( + (Gdk.ModifierType.CONTROL_MASK, "Ctrl"), + (Gdk.ModifierType.ALT_MASK, "Alt"), + (Gdk.ModifierType.SHIFT_MASK, "Shift"), + (Gdk.ModifierType.SUPER_MASK, "Super"), + ) + for mask, token in mapping: + if state & mask: + modifiers.append(token) + return modifiers + + +def _is_modifier_keyval(keyval: int) -> bool: + modifier_keys = ( + Gdk.KEY_Control_L, + Gdk.KEY_Control_R, + Gdk.KEY_Shift_L, + Gdk.KEY_Shift_R, + Gdk.KEY_Alt_L, + Gdk.KEY_Alt_R, + Gdk.KEY_Super_L, + Gdk.KEY_Super_R, + Gdk.KEY_Meta_L, + Gdk.KEY_Meta_R, + ) + return keyval in modifier_keys + + +def _shortcut_key_token_from_keyval(keyval: int) -> str | None: + if _is_modifier_keyval(keyval): + return None + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + return "Enter" + if keyval == Gdk.KEY_ISO_Left_Tab: + return "Tab" + + key_name = Gdk.keyval_name(keyval) or "" + if not key_name: + return None + if key_name in ("space", "Space"): + return "Space" + + if key_name.startswith("KP_") and len(key_name) == 4 and key_name[-1].isdigit(): + return key_name[-1] + + compact = key_name.replace("_", "") + if len(compact) == 1: + return compact.upper() + return compact + + def _normalize_bus_name(unique_name: str) -> str: return unique_name.lstrip(":").replace(".", "_") @@ -89,6 +147,7 @@ def __init__( self._signal_sub_id: int | None = None self._hotkey_entry: Gtk.Entry | None = None self._hotkey_value_label: Gtk.Label | None = None + self._present_after_capture = False self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self._main_box.set_margin_top(16) @@ -147,6 +206,9 @@ def _build_hotkey_settings(self) -> None: self._hotkey_entry.set_hexpand(True) self._hotkey_entry.set_placeholder_text(DEFAULT_SHORTCUT) self._hotkey_entry.connect("activate", self._on_hotkey_entry_activate) + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_hotkey_entry_key_pressed) + self._hotkey_entry.add_controller(key_controller) edit_row.append(self._hotkey_entry) apply_btn = Gtk.Button(label="Apply") @@ -157,7 +219,7 @@ def _build_hotkey_settings(self) -> None: default_btn.connect("clicked", self._on_hotkey_restore_default) edit_row.append(default_btn) - disable_btn = Gtk.Button(label="Disable") + disable_btn = Gtk.Button(label="Clear") disable_btn.connect("clicked", self._on_hotkey_disable) edit_row.append(disable_btn) @@ -171,13 +233,13 @@ def _refresh_hotkey_ui(self) -> None: if self._hotkey_value_label is not None: self._hotkey_value_label.set_text(current or "Disabled") if self._hotkey_entry is not None: - self._hotkey_entry.set_text(current or "") + self._hotkey_entry.set_text(_shortcut_display_text(current) if current else "") def _apply_hotkey_result(self, result: Any) -> None: if self._hotkey_value_label is not None: self._hotkey_value_label.set_text(result.shortcut or "Disabled") if self._hotkey_entry is not None: - self._hotkey_entry.set_text(result.shortcut or "") + self._hotkey_entry.set_text(_shortcut_display_text(result.shortcut) if result.shortcut else "") if result.warning: self.set_nonblocking_warning(result.warning) return @@ -188,7 +250,7 @@ def _on_hotkey_apply(self, _button: Gtk.Button) -> None: return user_value = self._hotkey_entry.get_text().strip() if not user_value: - self._set_status("Failed: shortcut cannot be empty (use Disable)") + self._set_status("Failed: shortcut cannot be empty (use Clear)") return try: result = self._hotkey_manager.apply_shortcut(user_value) @@ -197,6 +259,34 @@ def _on_hotkey_apply(self, _button: Gtk.Button) -> None: return self._apply_hotkey_result(result) + def _on_hotkey_entry_key_pressed( + self, + _controller: Gtk.EventControllerKey, + keyval: int, + _keycode: int, + state: int, + ) -> bool: + if self._hotkey_entry is None: + return False + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + return False + + key_token = _shortcut_key_token_from_keyval(keyval) + if key_token is None: + return True + + candidate = "+".join([*_shortcut_modifiers_from_state(state), key_token]) + try: + normalized = normalize_shortcut(candidate) + except ValueError as err: + self._set_status(f"Failed: invalid shortcut ({err})") + return True + + self._hotkey_entry.set_text(_shortcut_display_text(normalized)) + self._hotkey_entry.set_position(-1) + self._set_status("Ready") + return True + def _on_hotkey_entry_activate(self, _entry: Gtk.Entry) -> None: self._on_hotkey_apply(_entry) @@ -212,6 +302,10 @@ def _on_hotkey_disable(self, _button: Gtk.Button) -> None: result = self._hotkey_manager.disable_shortcut() self._apply_hotkey_result(result) + def trigger_shortcut_capture(self) -> None: + self._present_after_capture = True + self.take_screenshot() + def _build_handle_token(self) -> str: self._request_counter += 1 return f"screenux_{os.getpid()}_{self._request_counter}_{int(time.time() * 1000)}" @@ -228,6 +322,9 @@ def _finish(self, status: str) -> None: self._unsubscribe_signal() self._button.set_sensitive(True) self._set_status(status) + if getattr(self, "_present_after_capture", False): + self.present() + self._present_after_capture = False def _fail(self, reason: str) -> None: self._finish(f"Failed: {reason}") @@ -344,6 +441,9 @@ def _save_uri(self, source_uri: str) -> None: on_error=self._on_editor_error, ) self.set_child(editor) + if getattr(self, "_present_after_capture", False): + self.present() + self._present_after_capture = False def _on_editor_save(self, saved_path: Path) -> None: self._show_main_panel() diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 2fc3e3a..90da4fa 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -35,6 +35,9 @@ def get_text(self): def set_text(self, text): self.text = text + def set_position(self, _position): + return None + class DummyHotkeyManager: def __init__(self): @@ -79,6 +82,8 @@ def __init__(self): self._request_counter = 0 self._bus = None self._signal_sub_id = None + self._present_after_capture = False + self._present_calls = 0 self._button = DummyButton() self._status_label = DummyLabel() self._main_box = object() @@ -112,6 +117,9 @@ def __init__(self): def set_child(self, child): self._set_child_value = child + def present(self): + self._present_calls += 1 + def test_window_take_screenshot_public_method(): self = FakeWindowSelf() @@ -123,6 +131,27 @@ def test_window_take_screenshot_public_method(): assert called == [self._button] +def test_window_trigger_shortcut_capture_sets_deferred_presentation_flag(): + self = FakeWindowSelf() + calls = [] + self.take_screenshot = lambda: calls.append("capture") + + window.MainWindow.trigger_shortcut_capture(self) + + assert calls == ["capture"] + assert self._present_after_capture is True + + +def test_window_finish_presents_when_shortcut_capture_is_deferred(): + self = FakeWindowSelf() + self._present_after_capture = True + + window.MainWindow._finish(self, "Done") + + assert self._present_calls == 1 + assert self._present_after_capture is False + + def test_window_hotkey_apply_accepts_printscreen_alias(): manager = DummyHotkeyManager() self = SimpleNamespace( @@ -141,6 +170,23 @@ def test_window_hotkey_apply_accepts_printscreen_alias(): assert self._applied_result.shortcut == "ctrl+print screen" +def test_window_hotkey_apply_rejects_empty_and_mentions_clear(): + manager = DummyHotkeyManager() + self = SimpleNamespace( + _hotkey_manager=manager, + _hotkey_entry=DummyEntry(""), + _set_status=lambda text: setattr(self, "_status_text", text), + _apply_hotkey_result=lambda result: setattr(self, "_applied_result", result), + ) + self._status_text = "" + self._applied_result = None + + window.MainWindow._on_hotkey_apply(self, None) + + assert "use Clear" in self._status_text + assert self._applied_result is None + + def test_window_hotkey_restore_default(): manager = DummyHotkeyManager() self = SimpleNamespace( @@ -167,6 +213,101 @@ def test_window_hotkey_entry_activate_triggers_apply(): assert calls == ["entry"] +def test_window_hotkey_capture_builds_shortcut_from_key_event(monkeypatch): + fake_gdk = SimpleNamespace( + ModifierType=SimpleNamespace( + CONTROL_MASK=1, + ALT_MASK=2, + SHIFT_MASK=4, + SUPER_MASK=8, + ), + KEY_Control_L=1000, + KEY_Control_R=1001, + KEY_Shift_L=1002, + KEY_Shift_R=1003, + KEY_Alt_L=1004, + KEY_Alt_R=1005, + KEY_Super_L=1006, + KEY_Super_R=1007, + KEY_Meta_L=1008, + KEY_Meta_R=1009, + KEY_ISO_Left_Tab=1010, + KEY_Return=1011, + KEY_KP_Enter=1012, + keyval_name=lambda keyval: {115: "s", 1111: "Print", 1112: "F5"}.get(keyval), + ) + monkeypatch.setattr(window, "Gdk", fake_gdk) + + self = SimpleNamespace( + _hotkey_entry=DummyEntry(""), + _set_status=lambda text: setattr(self, "_status_text", text), + ) + self._status_text = "" + + consumed = window.MainWindow._on_hotkey_entry_key_pressed( + self, + None, + 115, + 0, + fake_gdk.ModifierType.CONTROL_MASK | fake_gdk.ModifierType.SHIFT_MASK, + ) + assert consumed is True + assert self._hotkey_entry.text == "Ctrl + Shift + S" + + consumed_modifier = window.MainWindow._on_hotkey_entry_key_pressed( + self, + None, + fake_gdk.KEY_Control_L, + 0, + fake_gdk.ModifierType.CONTROL_MASK, + ) + assert consumed_modifier is True + assert self._hotkey_entry.text == "Ctrl + Shift + S" + + +def test_window_hotkey_capture_ignores_unmapped_key(monkeypatch): + fake_gdk = SimpleNamespace( + ModifierType=SimpleNamespace( + CONTROL_MASK=1, + ALT_MASK=2, + SHIFT_MASK=4, + SUPER_MASK=8, + ), + KEY_Control_L=1000, + KEY_Control_R=1001, + KEY_Shift_L=1002, + KEY_Shift_R=1003, + KEY_Alt_L=1004, + KEY_Alt_R=1005, + KEY_Super_L=1006, + KEY_Super_R=1007, + KEY_Meta_L=1008, + KEY_Meta_R=1009, + KEY_ISO_Left_Tab=1010, + KEY_Return=1011, + KEY_KP_Enter=1012, + keyval_name=lambda _keyval: None, + ) + monkeypatch.setattr(window, "Gdk", fake_gdk) + + self = SimpleNamespace( + _hotkey_entry=DummyEntry("Ctrl + S"), + _set_status=lambda text: setattr(self, "_status_text", text), + ) + self._status_text = "" + + consumed = window.MainWindow._on_hotkey_entry_key_pressed( + self, + None, + 61, + 0, + 0, + ) + assert consumed is True + assert self._hotkey_entry.text == "Ctrl + S" + assert self._status_text == "" + + class DummyError(Exception): def __init__(self, message): self.message = message @@ -524,6 +665,53 @@ def get_arguments(): assert app.activated is True +def test_screenshot_app_do_activate_auto_capture_skips_initial_present(monkeypatch): + if not hasattr(screenshot.ScreenuxScreenshotApp, "do_activate"): + return + + created = {} + + class FakeWindow: + def __init__(self, *_args, **_kwargs): + self.present_calls = 0 + self.capture_calls = 0 + created["window"] = self + + def present(self): + self.present_calls += 1 + + def set_icon_name(self, _name): + return None + + def set_nonblocking_warning(self, _text): + return None + + def trigger_shortcut_capture(self): + self.capture_calls += 1 + + class FakeGtkWindow: + @staticmethod + def set_default_icon_name(_name): + return None + + monkeypatch.setattr(screenshot, "MainWindow", FakeWindow) + monkeypatch.setattr(screenshot, "Gtk", SimpleNamespace(Window=FakeGtkWindow)) + + app = SimpleNamespace( + _auto_capture_pending=True, + _hotkey_manager=SimpleNamespace( + ensure_registered=lambda: SimpleNamespace(warning=None) + ), + props=SimpleNamespace(active_window=None), + ) + + screenshot.ScreenuxScreenshotApp.do_activate(app) + + assert app._auto_capture_pending is False + assert created["window"].capture_calls == 1 + assert created["window"].present_calls == 0 + + def test_screenshot_enforce_offline_mode_blocks_network(monkeypatch): original_socket = screenshot.socket.socket original_create_connection = screenshot.socket.create_connection From 8b85e79c520f26bf3a3d67e5bb4afbaebf0f16c9 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:08:04 -0300 Subject: [PATCH 03/15] feat: update default save directory to Pictures/Screenshots; adjust tests accordingly --- README.md | 6 +++--- src/screenux_screenshot.py | 20 ++++++++++++++------ tests/test_paths.py | 26 ++++++++++++++++---------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 14f1030..823b847 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - 🧭 Clean interface with one primary action and clear status messages - 🔒 Local-first behavior (no cloud upload flow) - 🖼️ Wayland-friendly capture via desktop portal APIs -- 📁 Practical folder defaults (Desktop, then Home fallback) +- 📁 Practical folder defaults (`Pictures/Screenshots`, then Home fallback) ## 🧩 Features @@ -126,8 +126,8 @@ Preserve app data in `~/.var/app/io.github.rafa.ScreenuxScreenshot`: Save folder behavior: -- Default target is Desktop. -- If Desktop is unavailable or not writable, Screenux falls back to Home. +- Default target is `Pictures/Screenshots` (created automatically when possible). +- If `Pictures/Screenshots` is unavailable or not writable, Screenux falls back to Home. - You can change the destination from the app (`Save to` → `Change…`). Global hotkey behavior: diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 5eaa1f7..b6fd063 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -134,14 +134,22 @@ def resolve_save_dir() -> Path: if custom_path.is_dir() and os.access(custom_path, os.W_OK | os.X_OK): return custom_path - desktop_dir: str | None = None + pictures_dir: str | None = None if GLib is not None: - desktop_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP) + try: + pictures_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) + except Exception: + pictures_dir = None - if desktop_dir: - desktop_path = Path(desktop_dir).expanduser() - if desktop_path.is_dir() and os.access(desktop_path, os.W_OK | os.X_OK): - return desktop_path + base_dir = Path(pictures_dir).expanduser() if pictures_dir else (Path.home() / "Pictures") + screenshots_dir = base_dir / "Screenshots" + try: + screenshots_dir.mkdir(parents=True, exist_ok=True) + except OSError: + pass + else: + if screenshots_dir.is_dir() and os.access(screenshots_dir, os.W_OK | os.X_OK): + return screenshots_dir return Path.home() diff --git a/tests/test_paths.py b/tests/test_paths.py index af30492..c3da1f1 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -9,34 +9,37 @@ import screenux_screenshot as app -def test_resolve_save_dir_uses_desktop_when_available(tmp_path, monkeypatch): - desktop = tmp_path / "Desktop" - desktop.mkdir() +def test_resolve_save_dir_uses_screenshots_dir_when_available(tmp_path, monkeypatch): + pictures = tmp_path / "Pictures" + pictures.mkdir() + screenshots = pictures / "Screenshots" home = tmp_path / "home" home.mkdir() class FakeGLib: class UserDirectory: - DIRECTORY_DESKTOP = object() + DIRECTORY_PICTURES = object() @staticmethod def get_user_special_dir(_directory): - return str(desktop) + return str(pictures) monkeypatch.setattr(app, "GLib", FakeGLib) monkeypatch.setattr(app, "load_config", lambda: {}) monkeypatch.setattr(app.Path, "home", staticmethod(lambda: home)) - assert app.resolve_save_dir() == desktop + assert app.resolve_save_dir() == screenshots + assert screenshots.is_dir() -def test_resolve_save_dir_falls_back_to_home_when_desktop_missing(tmp_path, monkeypatch): +def test_resolve_save_dir_uses_home_screenshots_when_pictures_dir_missing(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() + screenshots = home / "Pictures" / "Screenshots" class FakeGLib: class UserDirectory: - DIRECTORY_DESKTOP = object() + DIRECTORY_PICTURES = object() @staticmethod def get_user_special_dir(_directory): @@ -46,7 +49,8 @@ def get_user_special_dir(_directory): monkeypatch.setattr(app, "load_config", lambda: {}) monkeypatch.setattr(app.Path, "home", staticmethod(lambda: home)) - assert app.resolve_save_dir() == home + assert app.resolve_save_dir() == screenshots + assert screenshots.is_dir() def test_resolve_save_dir_uses_config_when_set(tmp_path, monkeypatch): @@ -65,12 +69,14 @@ def test_resolve_save_dir_uses_config_when_set(tmp_path, monkeypatch): def test_resolve_save_dir_ignores_invalid_config_dir(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() + screenshots = home / "Pictures" / "Screenshots" monkeypatch.setattr(app, "load_config", lambda: {"save_dir": "/nonexistent/path"}) monkeypatch.setattr(app, "GLib", None) monkeypatch.setattr(app.Path, "home", staticmethod(lambda: home)) - assert app.resolve_save_dir() == home + assert app.resolve_save_dir() == screenshots + assert screenshots.is_dir() def test_build_output_path_preserves_extension(monkeypatch, tmp_path): From a595bd12b5f7b3bc175cd38facceff9cae909c2f Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:10:46 -0300 Subject: [PATCH 04/15] feat: enhance initial window size and centering functionality; add tests for new helpers --- README.md | 1 + src/screenux_screenshot.py | 5 +- src/screenux_window.py | 74 +++++++++++++++++++++++++++-- tests/test_window_and_screenshot.py | 17 +++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 823b847..1b38f67 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes - Packaged app icon for desktop launcher integration +- Wider default window for comfortable hotkey editing, with centered initial presentation (best-effort by desktop/session) ## Install diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index b6fd063..66497a6 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -239,7 +239,10 @@ def do_activate(self) -> None: if auto_capture and hasattr(window, "trigger_shortcut_capture"): window.trigger_shortcut_capture() return - window.present() + if hasattr(window, "present_with_initial_center"): + window.present_with_initial_center() + else: + window.present() if auto_capture: GLib.idle_add(self._trigger_auto_capture, window) else: diff --git a/src/screenux_window.py b/src/screenux_window.py index f1dc1f3..f4f4c18 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -19,6 +19,27 @@ PORTAL_PATH = "/org/freedesktop/portal/desktop" PORTAL_SCREENSHOT_IFACE = "org.freedesktop.portal.Screenshot" PORTAL_REQUEST_IFACE = "org.freedesktop.portal.Request" +_DEFAULT_WINDOW_WIDTH = 520 +_DEFAULT_WINDOW_HEIGHT = 220 + + +def _initial_window_size() -> tuple[int, int]: + return (_DEFAULT_WINDOW_WIDTH, _DEFAULT_WINDOW_HEIGHT) + + +def _center_position( + *, + monitor_x: int, + monitor_y: int, + monitor_width: int, + monitor_height: int, + window_width: int, + window_height: int, +) -> tuple[int, int]: + return ( + monitor_x + (monitor_width - window_width) // 2, + monitor_y + (monitor_height - window_height) // 2, + ) def _shortcut_display_text(shortcut: str) -> str: @@ -133,7 +154,8 @@ def __init__( ): super().__init__(application=app, title="Screenux Screenshot") self.set_icon_name(icon_name) - self.set_default_size(360, 180) + width, height = _initial_window_size() + self.set_default_size(width, height) self._resolve_save_dir = resolve_save_dir self._load_config = load_config @@ -148,6 +170,7 @@ def __init__( self._hotkey_entry: Gtk.Entry | None = None self._hotkey_value_label: Gtk.Label | None = None self._present_after_capture = False + self._did_initial_center = False self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self._main_box.set_margin_top(16) @@ -306,6 +329,45 @@ def trigger_shortcut_capture(self) -> None: self._present_after_capture = True self.take_screenshot() + def center_on_screen_once(self) -> None: + if self._did_initial_center: + return + if self._try_center_window(): + self._did_initial_center = True + + def _try_center_window(self) -> bool: + try: + surface = self.get_surface() + if surface is None: + return False + display = surface.get_display() if hasattr(surface, "get_display") else None + if display is None or not hasattr(display, "get_monitor_at_surface"): + return False + monitor = display.get_monitor_at_surface(surface) + if monitor is None or not hasattr(monitor, "get_geometry"): + return False + geometry = monitor.get_geometry() + width, height = _initial_window_size() + x, y = _center_position( + monitor_x=int(getattr(geometry, "x", 0)), + monitor_y=int(getattr(geometry, "y", 0)), + monitor_width=int(getattr(geometry, "width", width)), + monitor_height=int(getattr(geometry, "height", height)), + window_width=width, + window_height=height, + ) + mover = surface if hasattr(surface, "move") else self + if not hasattr(mover, "move"): + return False + mover.move(x, y) + return True + except Exception: + return False + + def present_with_initial_center(self) -> None: + self.center_on_screen_once() + self.present() + def _build_handle_token(self) -> str: self._request_counter += 1 return f"screenux_{os.getpid()}_{self._request_counter}_{int(time.time() * 1000)}" @@ -323,7 +385,10 @@ def _finish(self, status: str) -> None: self._button.set_sensitive(True) self._set_status(status) if getattr(self, "_present_after_capture", False): - self.present() + if hasattr(self, "present_with_initial_center"): + self.present_with_initial_center() + else: + self.present() self._present_after_capture = False def _fail(self, reason: str) -> None: @@ -442,7 +507,10 @@ def _save_uri(self, source_uri: str) -> None: ) self.set_child(editor) if getattr(self, "_present_after_capture", False): - self.present() + if hasattr(self, "present_with_initial_center"): + self.present_with_initial_center() + else: + self.present() self._present_after_capture = False def _on_editor_save(self, saved_path: Path) -> None: diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 90da4fa..314d337 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -324,6 +324,23 @@ def test_window_top_level_helpers(): assert window._extract_uri({"uri": "file:///tmp/y.png"}) == "file:///tmp/y.png" +def test_window_initial_size_and_center_helpers(): + width, height = window._initial_window_size() + assert width >= 480 + assert height >= 180 + + x, y = window._center_position( + monitor_x=100, + monitor_y=50, + monitor_width=1920, + monitor_height=1080, + window_width=width, + window_height=height, + ) + assert x == 100 + (1920 - width) // 2 + assert y == 50 + (1080 - height) // 2 + + def test_window_uri_to_local_path(tmp_path): local = tmp_path / "cap.png" local.write_bytes(b"x") From 190accf9c8612049b6cf65884d12ec68cbc4d57e Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:17:00 -0300 Subject: [PATCH 05/15] feat: add color picker support for older GTK4 runtimes in annotation editor; enhance tests for color button creation --- README.md | 1 + src/screenux_editor.py | 34 ++++++++++++---- tests/test_editor_logic.py | 80 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1b38f67..0c49985 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Default global hotkey: `Ctrl+Print` - Status updates: `Ready`, `Capturing...`, `Saved: `, `Cancelled`, `Failed: ` - Built-in editor for quick annotations (shapes/text) +- Editor color picker supports older GTK4 runtimes used by some distro `.deb` installs - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes - Packaged app icon for desktop launcher integration diff --git a/src/screenux_editor.py b/src/screenux_editor.py index 0ee4c56..c186548 100644 --- a/src/screenux_editor.py +++ b/src/screenux_editor.py @@ -143,6 +143,30 @@ def _make_text_annotation(text: str, position: Point, color: Color) -> Annotatio } +def _create_color_button(on_color_changed: Callable[..., None]): + rgba = Gdk.RGBA() + rgba.parse("red") + + color_dialog_cls = getattr(Gtk, "ColorDialog", None) + color_dialog_button_cls = getattr(Gtk, "ColorDialogButton", None) + if callable(color_dialog_cls) and callable(color_dialog_button_cls): + color_btn = color_dialog_button_cls(dialog=color_dialog_cls()) + color_btn.connect("notify::rgba", on_color_changed) + else: + color_button_cls = getattr(Gtk, "ColorButton", None) + if not callable(color_button_cls): + raise RuntimeError("no compatible GTK color picker found") + color_btn = color_button_cls() + try: + color_btn.connect("notify::rgba", on_color_changed) + except TypeError: + color_btn.connect("color-set", on_color_changed) + + color_btn.set_rgba(rgba) + color_btn.set_tooltip_text("Annotation color") + return color_btn + + def _write_surface_png_securely(surface, dest: Path) -> None: destination = dest.expanduser().resolve() parent = destination.parent @@ -293,13 +317,7 @@ def _tool_btn(icon_file: str, fallback_label: str, tooltip: str, tool_name: str) toolbar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - color_dialog = Gtk.ColorDialog() - self._color_btn = Gtk.ColorDialogButton(dialog=color_dialog) - rgba = Gdk.RGBA() - rgba.parse("red") - self._color_btn.set_rgba(rgba) - self._color_btn.connect("notify::rgba", self._on_color_changed) - self._color_btn.set_tooltip_text("Annotation color") + self._color_btn = _create_color_button(self._on_color_changed) toolbar.append(self._color_btn) toolbar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) @@ -521,7 +539,7 @@ def _on_tool_toggled(self, button: Gtk.ToggleButton, tool_name: str) -> None: if hasattr(self, "_drawing_area"): self._drawing_area.queue_draw() - def _on_color_changed(self, button: Gtk.ColorDialogButton, _pspec) -> None: + def _on_color_changed(self, button, _pspec=None) -> None: rgba = button.get_rgba() self._current_color = (rgba.red, rgba.green, rgba.blue, rgba.alpha) diff --git a/tests/test_editor_logic.py b/tests/test_editor_logic.py index 195fdd9..c19c5fe 100644 --- a/tests/test_editor_logic.py +++ b/tests/test_editor_logic.py @@ -282,6 +282,86 @@ def test_editor_core_methods(monkeypatch): assert editor.AnnotationEditor._annotation_moved(self, ann_a, ann_b) +def test_create_color_button_uses_color_dialog_when_available(monkeypatch): + class DummyRGBAParsed: + def __init__(self): + self.parsed = None + + def parse(self, text): + self.parsed = text + return True + + class DummyColorDialog: + pass + + class DummyColorDialogButton: + def __init__(self, dialog): + self.dialog = dialog + self.connected = [] + self.rgba = None + self.tooltip = None + + def connect(self, signal, _cb): + self.connected.append(signal) + + def set_rgba(self, rgba): + self.rgba = rgba + + def set_tooltip_text(self, text): + self.tooltip = text + + monkeypatch.setattr( + editor, + "Gtk", + SimpleNamespace(ColorDialog=DummyColorDialog, ColorDialogButton=DummyColorDialogButton), + ) + monkeypatch.setattr(editor, "Gdk", SimpleNamespace(RGBA=DummyRGBAParsed)) + + button = editor._create_color_button(lambda *_: None) + + assert isinstance(button, DummyColorDialogButton) + assert isinstance(button.dialog, DummyColorDialog) + assert button.connected == ["notify::rgba"] + assert button.rgba.parsed == "red" + assert button.tooltip == "Annotation color" + + +def test_create_color_button_falls_back_when_color_dialog_unavailable(monkeypatch): + class DummyRGBAParsed: + def __init__(self): + self.parsed = None + + def parse(self, text): + self.parsed = text + return True + + class DummyColorButton: + def __init__(self): + self.connected = [] + self.rgba = None + self.tooltip = None + + def connect(self, signal, _cb): + if signal == "notify::rgba": + raise TypeError("unknown signal") + self.connected.append(signal) + + def set_rgba(self, rgba): + self.rgba = rgba + + def set_tooltip_text(self, text): + self.tooltip = text + + monkeypatch.setattr(editor, "Gtk", SimpleNamespace(ColorButton=DummyColorButton)) + monkeypatch.setattr(editor, "Gdk", SimpleNamespace(RGBA=DummyRGBAParsed)) + + button = editor._create_color_button(lambda *_: None) + + assert isinstance(button, DummyColorButton) + assert button.connected == ["color-set"] + assert button.rgba.parsed == "red" + assert button.tooltip == "Annotation color" + def test_editor_draw_drag_click_and_keys(monkeypatch): self = FakeEditorSelf() self._surface = FakeSurface(100, 100) From 530573009971985f36168fe0b605ae11907a3bd1 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:21:55 -0300 Subject: [PATCH 06/15] feat: update icon designs for dark and light themes; enhance visual consistency across assets --- ...io.github.rafa.ScreenuxScreenshot-dark.svg | 7 ++++--- ...o.github.rafa.ScreenuxScreenshot-light.svg | 7 ++++--- .../io.github.rafa.ScreenuxScreenshot.png | Bin 1879 -> 7987 bytes .../io.github.rafa.ScreenuxScreenshot.svg | 12 ++++-------- packaging/linux/screenux-screenshot.png | Bin 1879 -> 7987 bytes 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg index 12b81be..faed744 100644 --- a/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg index 082fea6..faed744 100644 --- a/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot.png b/assets/icons/io.github.rafa.ScreenuxScreenshot.png index afdb84e53f2ff9f21dd1020cd4863e3b5bf3c61b..c5bcb671dbe3959887cd40024ccabb45316015db 100644 GIT binary patch literal 7987 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 Z7*vx8{dHpP4_3heZFN1hauutv{{x}C((C{L literal 1879 zcmaJ?dpOitA3wjJ(aebDO-hvU!-z5pVQ0l^&HR>q;aevCCF~&}IpPu)R_C5cc^F8N#JD>0Oe9rm(`u;A;$ftdIx67VZU45NitD&_2+~^V2i*dXgxO~OZ*Gs#q(=?fa@()I{ z7Yf8KU!46-rTaZ2eZv|Hboto*Lwj`Z+LPSd`9EkRTt^qpirs1rgQEy~`JN9@O+-FX z$Pec1l~Th+CG?C`f8Q@XRNOp3cD|ZdWwY^>kYUx5+Royihw!I}H zMK->8;N?xD7x{^pRTXzGMYs$v!A0AYTIg%zb)kUfXIXcsGuB)m2oG&(Bt*r{a? zP2it*3nJJq^WwAO2s?l!QEpL=W5A?pQ%EwQS@mpmtu{dE8-O-Ngm_f382uJEKD3+> z&L_ef<#KuCyc0bc)LkxSHqY8)69oFQ$~&DcT=&7&6GsSAyLddZ%aTJpXXm>EGe05# z+bp@^^9w~spz249Rn|Kzs@n@?PmG24_L659=2*@J0qVJ2{BnWo3}{eFmkg3C-yU9)DMYoUasA8ViPug7`$FgR zLiz- zmtqb(fNyuUnP&SLh}+MIkR$-wv;oU{Y2hf78G-}Yz|Bize#A)?P)7kd1pIsum$?pT zQ2>bmfhH6{X@5qTMwykqQ!!~w_PGE|Zm>IWw)Xpu5c1s+n3}yplSUp2fHR#{7PF?r z6mTV2kx|jQ8TN;taPp!yd3*p3bI0eyoXg}r8M+=3y zb9E?_o9uTuw{O~XFbFwkZYr>$MGH9I%+=|*SiAk-Gy8P%^o{W6O1t1Uwz9)0QqpO^ zmPkW&85^r5dEpddm)@I?<^9UCtX`O32!ey9Vpezu$phvUG-Mydyf*@dnIJf5nt#`n zw-I{&Tj|JpI1U3a_|^S@_w=cqJoKkP|BIA+3Ww4CrpfM;^3hlHlk(~BfF(bgeL~^4 z3a7?o)b&&I(@wy0ZX&2tZujGkn80(zaiYR{J;82m65{j={oMi!#zgi#<>+5hfwY{j zuqEq2JocXq5P$ez1e2|k-KSRZAta$A{)X)A%^aNr7ul)?X!DWho42$hdIAH8@Vvbp z7k)DSZP1u5=iq&mE+vx5ifdV|J4Ip^y;>*AjUMJ519lzO`o*q+UV%+2F$2p(Ug4eX zrd%n&u}?Oj=sYg-Mv=(bXFQef>;Lr2N3ewF+XkCk6?w`a6>%V@I2W7|po)ABfP zVmw57Cr6afC@_cb_*L2jYhjk#y9btd?%^q0I14PUeZsd|iuR>|hsSabIE2KC8jVv{ zdPj1R-Q)7Y+U5kXV6D4-Fe${m>b_2KJU zINVshi+`dc?IFVo>Km=gJVW*^w^}xXB~WsTdEy|7@rM8+h(;ANOn|X61{7%KbxI=v zB2^!U{M8U*&|LE?ED0m*vyZ@nreZW=iX7#Sb7MhhuiO#YwEb1wc>qRCw1Lh`t-ZIX z)5l={Ua6Vd#4T!Wh8hx9USmBI+9|DPfKZof9ZlRKGDQ+>eRkrQ*ihdz6 zP*U&&+O|eokNW-K{aH;{576pGr&uf5Is=P@`LH~HzemkA*8#ovx`xtOH8O^@Iq!QW zvfV30o-m9@0?W;98Y&p)M4Xue#n4_KrHku%;9^vdde@7h0q@O^O*_Epv6qYJ+h2@a zHIy8F@U?<0O0aR#ji{=0=T%9bNjK(fWBM;!KKGdc akU7MS|FE4H0Idu7sNmwb&!KXUfBauz@gqzC diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot.svg index 2823255..faed744 100644 --- a/assets/icons/io.github.rafa.ScreenuxScreenshot.svg +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot.svg @@ -1,10 +1,6 @@ - - - - - - - - + + + + diff --git a/packaging/linux/screenux-screenshot.png b/packaging/linux/screenux-screenshot.png index afdb84e53f2ff9f21dd1020cd4863e3b5bf3c61b..c5bcb671dbe3959887cd40024ccabb45316015db 100644 GIT binary patch literal 7987 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 Z7*vx8{dHpP4_3heZFN1hauutv{{x}C((C{L literal 1879 zcmaJ?dpOitA3wjJ(aebDO-hvU!-z5pVQ0l^&HR>q;aevCCF~&}IpPu)R_C5cc^F8N#JD>0Oe9rm(`u;A;$ftdIx67VZU45NitD&_2+~^V2i*dXgxO~OZ*Gs#q(=?fa@()I{ z7Yf8KU!46-rTaZ2eZv|Hboto*Lwj`Z+LPSd`9EkRTt^qpirs1rgQEy~`JN9@O+-FX z$Pec1l~Th+CG?C`f8Q@XRNOp3cD|ZdWwY^>kYUx5+Royihw!I}H zMK->8;N?xD7x{^pRTXzGMYs$v!A0AYTIg%zb)kUfXIXcsGuB)m2oG&(Bt*r{a? zP2it*3nJJq^WwAO2s?l!QEpL=W5A?pQ%EwQS@mpmtu{dE8-O-Ngm_f382uJEKD3+> z&L_ef<#KuCyc0bc)LkxSHqY8)69oFQ$~&DcT=&7&6GsSAyLddZ%aTJpXXm>EGe05# z+bp@^^9w~spz249Rn|Kzs@n@?PmG24_L659=2*@J0qVJ2{BnWo3}{eFmkg3C-yU9)DMYoUasA8ViPug7`$FgR zLiz- zmtqb(fNyuUnP&SLh}+MIkR$-wv;oU{Y2hf78G-}Yz|Bize#A)?P)7kd1pIsum$?pT zQ2>bmfhH6{X@5qTMwykqQ!!~w_PGE|Zm>IWw)Xpu5c1s+n3}yplSUp2fHR#{7PF?r z6mTV2kx|jQ8TN;taPp!yd3*p3bI0eyoXg}r8M+=3y zb9E?_o9uTuw{O~XFbFwkZYr>$MGH9I%+=|*SiAk-Gy8P%^o{W6O1t1Uwz9)0QqpO^ zmPkW&85^r5dEpddm)@I?<^9UCtX`O32!ey9Vpezu$phvUG-Mydyf*@dnIJf5nt#`n zw-I{&Tj|JpI1U3a_|^S@_w=cqJoKkP|BIA+3Ww4CrpfM;^3hlHlk(~BfF(bgeL~^4 z3a7?o)b&&I(@wy0ZX&2tZujGkn80(zaiYR{J;82m65{j={oMi!#zgi#<>+5hfwY{j zuqEq2JocXq5P$ez1e2|k-KSRZAta$A{)X)A%^aNr7ul)?X!DWho42$hdIAH8@Vvbp z7k)DSZP1u5=iq&mE+vx5ifdV|J4Ip^y;>*AjUMJ519lzO`o*q+UV%+2F$2p(Ug4eX zrd%n&u}?Oj=sYg-Mv=(bXFQef>;Lr2N3ewF+XkCk6?w`a6>%V@I2W7|po)ABfP zVmw57Cr6afC@_cb_*L2jYhjk#y9btd?%^q0I14PUeZsd|iuR>|hsSabIE2KC8jVv{ zdPj1R-Q)7Y+U5kXV6D4-Fe${m>b_2KJU zINVshi+`dc?IFVo>Km=gJVW*^w^}xXB~WsTdEy|7@rM8+h(;ANOn|X61{7%KbxI=v zB2^!U{M8U*&|LE?ED0m*vyZ@nreZW=iX7#Sb7MhhuiO#YwEb1wc>qRCw1Lh`t-ZIX z)5l={Ua6Vd#4T!Wh8hx9USmBI+9|DPfKZof9ZlRKGDQ+>eRkrQ*ihdz6 zP*U&&+O|eokNW-K{aH;{576pGr&uf5Is=P@`LH~HzemkA*8#ovx`xtOH8O^@Iq!QW zvfV30o-m9@0?W;98Y&p)M4Xue#n4_KrHku%;9^vdde@7h0q@O^O*_Epv6qYJ+h2@a zHIy8F@U?<0O0aR#ji{=0=T%9bNjK(fWBM;!KKGdc akU7MS|FE4H0Idu7sNmwb&!KXUfBauz@gqzC From 6c38c44d91f5540679805f8c164d03903447bce1 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 18:29:16 -0300 Subject: [PATCH 07/15] feat: enhance hotkey functionality with telemetry logging; improve README for clarity on GNOME conflicts --- README.md | 2 + src/screenux_hotkey.py | 38 ++++++++- src/screenux_screenshot.py | 28 +++++++ tests/test_hotkey_backend_gnome.py | 119 ++++++++++++++++++++++++++++ tests/test_hotkey_config.py | 6 ++ tests/test_window_and_screenshot.py | 18 +++++ 6 files changed, 208 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0c49985..7b1bb72 100644 --- a/README.md +++ b/README.md @@ -136,12 +136,14 @@ Global hotkey behavior: - Default shortcut is `Ctrl+Print`. - If it is already taken, Screenux falls back to `Ctrl+Shift+S` (then `Ctrl+Alt+S`, then `Alt+Shift+S`, then `Super+Shift+S`). +- On GNOME/Linux, native screenshot bindings (including clipboard variants like `Ctrl+Print`) are treated as conflicts, so Screenux automatically falls back instead of silently failing. - On GNOME, the shortcut is persisted as a GNOME custom shortcut and works when the app is closed. - On non-GNOME desktops, global shortcut support is best-effort while the app is running. - Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). - While the shortcut field is focused, press the key combo and Screenux builds it automatically (example: `Ctrl + S`). - You can apply with `Enter` or `Apply`. - You can return to default with `Default`, or clear/disable with `Clear`. +- Set `SCREENUX_LOG_LEVEL=INFO` to emit hotkey telemetry to stderr (registration resolution and `--capture` detection/handling) when debugging shortcut issues. ## 🖼️ UI example diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index e4a1ab9..98b4f30 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import re import subprocess # nosec B404 - required for trusted local command invocation. @@ -21,6 +22,7 @@ SCREENUX_CAPTURE_COMMAND = "screenux-screenshot --capture" Runner = Callable[[list[str]], object] +LOGGER = logging.getLogger("screenux.hotkey") _MODIFIER_ORDER = ("Ctrl", "Alt", "Shift", "Super") _MODIFIER_ALIASES = { @@ -46,6 +48,9 @@ (GNOME_MEDIA_SCHEMA, "screenshot"), (GNOME_MEDIA_SCHEMA, "window-screenshot"), (GNOME_MEDIA_SCHEMA, "area-screenshot"), + (GNOME_MEDIA_SCHEMA, "screenshot-clip"), + (GNOME_MEDIA_SCHEMA, "window-screenshot-clip"), + (GNOME_MEDIA_SCHEMA, "area-screenshot-clip"), ) @@ -74,6 +79,14 @@ def _success(result: object) -> bool: return int(getattr(result, "returncode", 1)) == 0 +def _log_telemetry(event: str, **fields: object) -> None: + details = ", ".join(f"{key}={fields[key]!r}" for key in sorted(fields)) + if details: + LOGGER.info("hotkey.%s %s", event, details) + return + LOGGER.info("hotkey.%s", event) + + def _normalize_key_token(token: str) -> str: text = token.strip() if not text: @@ -279,8 +292,10 @@ def _find_screenux_custom_path(paths: list[str], runner: Runner) -> str | None: def collect_gnome_taken_shortcuts(runner: Runner = _default_runner, exclude_path: str | None = None) -> set[str]: if not _gsettings_available(runner): + _log_telemetry("collect.skip", reason="gsettings-unavailable") return set() if not _schema_exists(GNOME_MEDIA_SCHEMA, runner): + _log_telemetry("collect.skip", reason="media-schema-unavailable") return set() taken: set[str] = set() @@ -300,6 +315,7 @@ def collect_gnome_taken_shortcuts(runner: Runner = _default_runner, exclude_path if parsed: taken.add(parsed) + _log_telemetry("collect.complete", total=len(taken), taken=sorted(taken)) return taken @@ -325,9 +341,12 @@ def register_gnome_shortcut( shortcut: str | None, runner: Runner = _default_runner, ) -> HotkeyRegistrationResult: + _log_telemetry("register.start", requested=shortcut) if not _gsettings_available(runner): + LOGGER.warning("hotkey.register.failed reason=gsettings-unavailable") return HotkeyRegistrationResult(shortcut, "gsettings is unavailable; global hotkey not configured.") if not _schema_exists(GNOME_MEDIA_SCHEMA, runner): + LOGGER.warning("hotkey.register.failed reason=media-schema-unavailable") return HotkeyRegistrationResult(shortcut, "GNOME media key schema not available; global hotkey not configured.") paths = _custom_paths(runner) @@ -335,14 +354,17 @@ def register_gnome_shortcut( if shortcut is None: _remove_screenux_shortcut(paths, runner) + _log_telemetry("register.disabled") return HotkeyRegistrationResult(None, None) preferred = normalize_shortcut(shortcut) taken = collect_gnome_taken_shortcuts(runner=runner, exclude_path=screenux_path) resolved, warning = resolve_shortcut_with_fallback(preferred, lambda candidate: candidate in taken) + _log_telemetry("register.resolve", preferred=preferred, resolved=resolved, warning=warning) if resolved is None: _remove_screenux_shortcut(paths, runner) + LOGGER.warning("hotkey.register.failed reason=no-available-shortcut preferred=%r", preferred) return HotkeyRegistrationResult(None, warning) target_path = screenux_path or _next_available_custom_path(paths) @@ -351,9 +373,19 @@ def register_gnome_shortcut( _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(paths), runner) target_schema = f"{GNOME_CUSTOM_SCHEMA}:{target_path}" - _gsettings_set(target_schema, "name", SCREENUX_SHORTCUT_NAME, runner) - _gsettings_set(target_schema, "command", SCREENUX_CAPTURE_COMMAND, runner) - _gsettings_set(target_schema, "binding", shortcut_to_gsettings_binding(resolved), runner) + name_set = _gsettings_set(target_schema, "name", SCREENUX_SHORTCUT_NAME, runner) + command_set = _gsettings_set(target_schema, "command", SCREENUX_CAPTURE_COMMAND, runner) + binding_value = shortcut_to_gsettings_binding(resolved) + binding_set = _gsettings_set(target_schema, "binding", binding_value, runner) + _log_telemetry( + "register.persisted", + binding=binding_value, + command_set=command_set, + name_set=name_set, + path=target_path, + resolved=resolved, + binding_set=binding_set, + ) return HotkeyRegistrationResult(resolved, warning) diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 66497a6..e4a2878 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import logging import os import socket import sys @@ -30,6 +31,8 @@ _MAX_CONFIG_SIZE = 64 * 1024 _ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} APP_VERSION = "0.1.0" +_LOG_LEVEL_ENV = "SCREENUX_LOG_LEVEL" +LOGGER = logging.getLogger("screenux.app") def _print_help() -> None: @@ -172,6 +175,20 @@ def format_status_saved(path: Path) -> str: return f"Saved: {path}" +def configure_logging() -> None: + level_name = os.environ.get(_LOG_LEVEL_ENV, "").strip().upper() + if not level_name: + return + + level = getattr(logging, level_name, None) + if not isinstance(level, int): + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s") + LOGGER.warning("Invalid %s value: %s", _LOG_LEVEL_ENV, level_name) + return + + logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s") + + def _parse_cli_args(argv: list[str]) -> tuple[list[str], bool]: filtered = [argv[0]] if argv else [] auto_capture = False @@ -210,6 +227,7 @@ def do_command_line(self, command_line: Gio.ApplicationCommandLine) -> int: _, auto_capture = _parse_cli_args(args) if auto_capture: self._auto_capture_pending = True + LOGGER.info("hotkey.event.detected source=command-line args=%s", args) self.activate() return 0 @@ -217,6 +235,11 @@ def do_activate(self) -> None: icon_name = select_icon_name() Gtk.Window.set_default_icon_name(icon_name) hotkey_result = self._hotkey_manager.ensure_registered() + LOGGER.info( + "hotkey.registration.result shortcut=%r warning=%r", + getattr(hotkey_result, "shortcut", None), + getattr(hotkey_result, "warning", None), + ) auto_capture = self._auto_capture_pending self._auto_capture_pending = False window = self.props.active_window @@ -237,6 +260,7 @@ def do_activate(self) -> None: if hotkey_result.warning and hasattr(window, "set_nonblocking_warning"): window.set_nonblocking_warning(hotkey_result.warning) if auto_capture and hasattr(window, "trigger_shortcut_capture"): + LOGGER.info("hotkey.event.handled source=activation mode=shortcut-trigger") window.trigger_shortcut_capture() return if hasattr(window, "present_with_initial_center"): @@ -244,6 +268,7 @@ def do_activate(self) -> None: else: window.present() if auto_capture: + LOGGER.info("hotkey.event.handled source=activation mode=idle-capture") GLib.idle_add(self._trigger_auto_capture, window) else: class ScreenuxScreenshotApp: # pragma: no cover @@ -252,6 +277,7 @@ def run(self, _argv: list[str]) -> int: def main(argv: list[str]) -> int: + configure_logging() if "--help" in argv or "-h" in argv: _print_help() return 0 @@ -264,6 +290,8 @@ def main(argv: list[str]) -> int: print(f"Missing GTK4/PyGObject dependencies: {GI_IMPORT_ERROR}", file=sys.stderr) return 1 _, auto_capture = _parse_cli_args(argv) + if auto_capture: + LOGGER.info("hotkey.event.detected source=main argv=%s", argv) app = ScreenuxScreenshotApp(auto_capture=auto_capture) return app.run(argv) diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index 54f274e..820d222 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -132,3 +132,122 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting ] for command in calls ) + + +def test_collect_gnome_taken_shortcuts_includes_native_clip_bindings(): + calls: list[list[str]] = [] + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "['Print']\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), + } + runner = _make_runner(mapping, calls) + + taken = hotkey.collect_gnome_taken_shortcuts(runner=runner) + + assert "Ctrl+Print" in taken + + +def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_conflicts(): + calls: list[list[str]] = [] + new_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "['Print']\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), + } + runner = _make_runner(mapping, calls) + + result = hotkey.register_gnome_shortcut("Ctrl+PrintScreen", runner=runner) + + assert result.shortcut == "Ctrl+Shift+S" + assert result.warning is not None + assert "Ctrl+Print" in result.warning + assert any( + command + == [ + "gsettings", + "set", + f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", + "binding", + "['s']", + ] + for command in calls + ) + + +def test_register_gnome_shortcut_emits_telemetry_logs(caplog): + calls: list[list[str]] = [] + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), + } + runner = _make_runner(mapping, calls) + + with caplog.at_level("INFO", logger="screenux.hotkey"): + result = hotkey.register_gnome_shortcut("Ctrl+Print", runner=runner) + + assert result.shortcut == "Ctrl+Print" + assert "hotkey.register.start" in caplog.text + assert "hotkey.register.resolve" in caplog.text + assert "hotkey.register.persisted" in caplog.text diff --git a/tests/test_hotkey_config.py b/tests/test_hotkey_config.py index aac15ec..04f4520 100644 --- a/tests/test_hotkey_config.py +++ b/tests/test_hotkey_config.py @@ -44,3 +44,9 @@ def _save(config): assert result.shortcut == hotkey.DEFAULT_SHORTCUT assert state["global_hotkey"] == hotkey.DEFAULT_SHORTCUT + + +def test_normalize_shortcut_accepts_printscreen_aliases_with_modifier_combo(): + assert hotkey.normalize_shortcut("ctrl+printscreen") == "Ctrl+Print" + assert hotkey.normalize_shortcut("CTRL+PrtScn") == "Ctrl+Print" + assert hotkey.normalize_shortcut("Shift+Print Screen") == "Shift+Print" diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 314d337..5c5079a 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -682,6 +682,24 @@ def get_arguments(): assert app.activated is True +def test_screenshot_app_command_line_emits_capture_detection_log(caplog): + if not hasattr(screenshot.ScreenuxScreenshotApp, "do_command_line"): + return + + app = SimpleNamespace(_auto_capture_pending=False, activate=lambda: None) + + class FakeCommandLine: + @staticmethod + def get_arguments(): + return ["screenux-screenshot", "--capture"] + + with caplog.at_level("INFO", logger="screenux.app"): + screenshot.ScreenuxScreenshotApp.do_command_line(app, FakeCommandLine()) + + assert "hotkey.event.detected source=command-line" in caplog.text + assert "--capture" in caplog.text + + def test_screenshot_app_do_activate_auto_capture_skips_initial_present(monkeypatch): if not hasattr(screenshot.ScreenuxScreenshotApp, "do_activate"): return From 85bd3590dedf333c6b6acd2b0c35241ad54465b9 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 20:09:58 -0300 Subject: [PATCH 08/15] feat: implement theme-aware toolbar icons with light/dark variants; enhance icon loading logic and add related tests --- README.md | 1 + src/icons/tool-circle-fill-dark.png | Bin 0 -> 424 bytes src/icons/tool-circle-fill-light.png | Bin 0 -> 430 bytes src/icons/tool-circle-outline-dark.png | Bin 0 -> 674 bytes src/icons/tool-circle-outline-light.png | Bin 0 -> 769 bytes src/icons/tool-rectangle-fill-dark.png | Bin 0 -> 136 bytes src/icons/tool-rectangle-fill-light.png | Bin 0 -> 137 bytes src/icons/tool-rectangle-outline-dark.png | Bin 0 -> 247 bytes src/icons/tool-rectangle-outline-light.png | Bin 0 -> 246 bytes src/icons/tool-select-dark.png | Bin 0 -> 335 bytes src/icons/tool-select-light.png | Bin 0 -> 375 bytes src/icons/tool-text-dark.png | Bin 0 -> 188 bytes src/icons/tool-text-light.png | Bin 0 -> 183 bytes src/screenux_editor.py | 88 +++++++++++++++++---- tests/test_editor_logic.py | 55 +++++++++++++ 15 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 src/icons/tool-circle-fill-dark.png create mode 100644 src/icons/tool-circle-fill-light.png create mode 100644 src/icons/tool-circle-outline-dark.png create mode 100644 src/icons/tool-circle-outline-light.png create mode 100644 src/icons/tool-rectangle-fill-dark.png create mode 100644 src/icons/tool-rectangle-fill-light.png create mode 100644 src/icons/tool-rectangle-outline-dark.png create mode 100644 src/icons/tool-rectangle-outline-light.png create mode 100644 src/icons/tool-select-dark.png create mode 100644 src/icons/tool-select-light.png create mode 100644 src/icons/tool-text-dark.png create mode 100644 src/icons/tool-text-light.png diff --git a/README.md b/README.md index 7b1bb72..50ccac7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Default global hotkey: `Ctrl+Print` - Status updates: `Ready`, `Capturing...`, `Saved: `, `Cancelled`, `Failed: ` - Built-in editor for quick annotations (shapes/text) +- Editor toolbar tool icons use bundled light/dark PNG assets with theme-aware selection (fallback when SVG loaders are unavailable in some `.deb` runtimes) - Editor color picker supports older GTK4 runtimes used by some distro `.deb` installs - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes diff --git a/src/icons/tool-circle-fill-dark.png b/src/icons/tool-circle-fill-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..641ca2671ae1123fd493dbd9ec1801a841e314e7 GIT binary patch literal 424 zcmV;Z0ayNsP)tdA2D=y-SC3fbE%j*S1imz%)*}3P%p*00^|FQPOc15Y=*b}3gF)q{=0tc>*|6y|;4!=Zmt71OtctO7w0ym`nOFey^*A*Z%;AeX?e zL@lg{*%x@UWd$B8Vw!nQ!ZcruX_R!*_1XgV(#vBYG{|5xsNENxSyEE6R(=4W0A&EN SUtQDy0000uP*6~?VJPbhzF+BR<@HFU31EM@ zJcdZ92*Ws@-u;U}5Y+0d`5J_lB|2iomy0NRU7Ntx&R&bO+ydB6lLwGM(OD8r!rTHu zP^+`>ONR0lGgjPKMA4fmq-?ve@RdV30QeqzH@1*b!1pU104EORzYtoba<%O&pp|#w zT)rw0^qd7qTy2?F`g%3;fLrCxLaK^9X#bLBP0&Q9y{SgT#u+v$KFFdXTqY z6NaYe)=B7>ckcx_b?hG{@#nEAV4sBF(g)$op?t=Q`?io(U_PI|gQyE&mZcnu&TaQV zDggjUqDjb#4*@)-h(EI8Mw*)taQGo9m8)$f=z*vS;9$8thDfLhgLyoC$hv1iLBU4( Y0sM7wMKheO`~Uy|07*qoM6N<$f{Pu(b^rhX literal 0 HcmV?d00001 diff --git a/src/icons/tool-circle-outline-dark.png b/src/icons/tool-circle-outline-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ce6374716f58241921d06a2fac07518af539aa86 GIT binary patch literal 674 zcmV;T0$u%yP)=4 z1XuMFhzoI{E4#3YN{ihpVyDsyiY^>G5c2X~Uy?a4I+;GF9h#(-Zsx3#$2qybguMGM zFwj7UEGZKT0*?EBawbLlH~>`O{fOi0=iy;#{x<-UqP4x%7P%+DB>==LDo#?Aw#!cO zg$;6KuiOSX5^1~OKrE3g{8ww$aT;n>PysgxbE%9uS)NO3}PB z$*l7NuM35Pfa6NrL}|-V+m)ilyAay0b|B&);Mi^+Z?{G9lXJ-SIq|YAfRnBIesVTH z0NO_#!?*JFrJE(&$8rOBgww^ORJ+c?|M27mNX7fLo#M&z)wi=E-p||sjH@33qyRuc z;T_-*WZA^p>Zd~4P}?`%W@3_=qP3MVSDkJ6s-JJyF8}}@IhS3N?T1Ah5$QeXE|8D% zW}OU0Y5SpQD{i$o+S7^`OSNJEFvd)}hf%0)Lz!f@pTE=nR#kz+DDQFn9Kg7<{1wE! zK-K_&;AH2AJLPzqLb+bA`-}Sm0JgqFO7Ag%3witimOP|)Yqi??UdEy$*$s%?CvX`% zN3ukKci0-qo*n2&)~g`kSZ63b0RROV07*qo IM6N<$f-}-XPXGV_ literal 0 HcmV?d00001 diff --git a/src/icons/tool-circle-outline-light.png b/src/icons/tool-circle-outline-light.png new file mode 100644 index 0000000000000000000000000000000000000000..026a06238a67614856543078c137b21a8b1cd75d GIT binary patch literal 769 zcmV+c1OEJpP)O%b?Ca&-oC<~*BZqyZt5>Z)dNI(N5A`}Bt3LC2JJudoT#c3U?Mi;!RJ9o}G?=v%Z z&b_d$ZT(|OD@ln&N6LxoVNtsaK*h|LHe_;rePQ`;0*L2j236z=a1wAf-7f)Sq6`N? z_CBD044^HY-X|q9B5*89yuure>E-;=(#&rIcwV|!#XJVu>kOs^o{7pVfQs%BMAdbwdaH2^LWbuZRzwrQ!=mX%%?={sp1=a*(1d6;H! zQZgfT=A+oc@kZtVxm@;%*g`ij3V@O_F}X*R2W<%8d6~hwZ$5=BT+HQi`6j>>h+JMv z@k@fduT1rLoD86& zp{VBda@+m)zz`IO$ArECXQw?%#N^n#DS#dTdYUaYSE$UDs^K!^9%Z4G(%AacdI5d{00000NkvXXu0mjf$unhT literal 0 HcmV?d00001 diff --git a/src/icons/tool-rectangle-fill-dark.png b/src/icons/tool-rectangle-fill-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..25d462c8d7e96e6cc41f85666e4fc01fc395201e GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_cXPZ!6K zh}O5~HgYitFt`Ttc>Vhyyj158L-&-YbH4JvZ)2zt^LKuoyw7HpYX1I9rzcA@v2X|| fI5aRY9w_HlPGeCKxv|y)Xb^*^tDnm{r-UW|Q8+53 literal 0 HcmV?d00001 diff --git a/src/icons/tool-rectangle-fill-light.png b/src/icons/tool-rectangle-fill-light.png new file mode 100644 index 0000000000000000000000000000000000000000..808f425ced4232378d553df163cf0e98b4157551 GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_c1PZ!6K zh}O5~Hu5qEFt`NfC`kSGUoNP;fuYyK+_cEZFw%hm8;k@TklqvqqKAEpY4BI&M4O# zGV&y+TFBe%diUgrlIx7+tCFYPJ-+$vp9#X;9MeUZ_-vSGaO4EB)E+*~-J@6_9C(~1 z-~{WGZWfm-?SJ1%DPP!PyWl(1>h%X$d>}|6=D5?nEwv#B0*;?%VVSZ}>u*bd#fN_F o4k ztPW*bvO50!aZ?wClSLl0L~qoxswFT%PxCI2bVFa3pJ`|Qzk{i3 j-~ZjYe6I43;|_B?;!X3=j#)ed>>mbCS3j3^P6xuoUG#Z%ab$V0s`8MZ{+h?js)GFp&fYYcWlCR28FG? z_V3zb85n#x5G!sypyJ81?|OMI fe$Z}i^5YR-@k)`p^D)an0mb0y>gTe~DWM4fswIS! literal 0 HcmV?d00001 diff --git a/src/icons/tool-select-light.png b/src/icons/tool-select-light.png new file mode 100644 index 0000000000000000000000000000000000000000..991c61dca5b7bd1b43d116be799ccc932610c04e GIT binary patch literal 375 zcmV--0f_#IP)jm6P(1iQ51Rm+|erOU%r2uryz*V z>jD5cqh%hB!_%v}00257GTw)hT>wO7~) zAYTr!ah?|1sw(z2%3t(&? z8#lsTOFTFmbeLuRJ_{ciV-PF!+--cS4BDW{^;@<%qDE5qa7 zrUa?qSJecJeVClT?^wK%XT4*z55w);`#W7-o~^cGx$!!4uY>a^DPxngh<{H$3kNhX hi7a4X)o|cYR97mLJzF`==_t^022WQ%mvv4FO#mXzLGu6r literal 0 HcmV?d00001 diff --git a/src/icons/tool-text-light.png b/src/icons/tool-text-light.png new file mode 100644 index 0000000000000000000000000000000000000000..967ad018d26969991d348eac7cbc6bb26d1f0c98 GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v}}DPZ!6K zh}Pr;3D(64OPJl9xL8_M&4QB-{@$)%^YP?nfy6d`RXzol85x-th7B_WZZ!FRuxD=8 zRAz3rnDTeh#Kwmvp|Nfp99-TxVj|Oi@E=WCKAEw str: + is_dark = False + if settings is not None: + prefers_dark = bool(settings.get_property("gtk-application-prefer-dark-theme")) + theme_name = str(settings.get_property("gtk-theme-name") or "").lower() + is_dark = prefers_dark or ("dark" in theme_name) + return "dark" if is_dark else "light" + + +def _toolbar_icon_color_from_variant(variant: str) -> str: + return _ICON_COLOR_DARK if variant == "dark" else _ICON_COLOR_LIGHT + + +def _tool_icon_candidates(icon_dir: Path, icon_name: str, variant: str) -> list[Path]: + return [ + icon_dir / f"{icon_name}-{variant}.png", + icon_dir / f"{icon_name}.png", + icon_dir / f"{icon_name}.svg", + ] def load_image_surface(file_path: str): @@ -273,8 +296,8 @@ def _build_toolbar(self) -> None: toolbar.set_margin_start(8) toolbar.set_margin_end(8) toolbar.set_margin_top(8) - icon_dir = Path(__file__).resolve().parent / "icons" - self._tool_icon_bindings: list[tuple[Gtk.Image, Path, str]] = [] + self._icon_dir = Path(__file__).resolve().parent / "icons" + self._tool_icon_bindings: list[tuple[Gtk.ToggleButton, Gtk.Image, str, str]] = [] settings = Gtk.Settings.get_default() if settings is not None: @@ -283,15 +306,11 @@ def _build_toolbar(self) -> None: def _tool_btn(icon_file: str, fallback_label: str, tooltip: str, tool_name: str) -> Gtk.ToggleButton: btn = Gtk.ToggleButton() - icon_path = icon_dir / icon_file + icon_name = Path(icon_file).stem image = Gtk.Image() image.set_pixel_size(18) - if icon_path.is_file(): - self._load_svg_icon(image, icon_path) - btn.set_child(image) - self._tool_icon_bindings.append((image, icon_path, fallback_label)) - else: - btn.set_child(Gtk.Label(label=fallback_label)) + self._set_tool_button_icon(btn, image, icon_name, fallback_label) + self._tool_icon_bindings.append((btn, image, icon_name, fallback_label)) btn.set_tooltip_text(tooltip) btn.connect("toggled", self._on_tool_toggled, tool_name) return btn @@ -381,13 +400,48 @@ def _tool_btn(icon_file: str, fallback_label: str, tooltip: str, tool_name: str) self.append(toolbar) def _toolbar_icon_color(self) -> str: + return _toolbar_icon_color_from_variant(self._toolbar_icon_variant()) + + def _toolbar_icon_variant(self) -> str: settings = Gtk.Settings.get_default() - is_dark = False - if settings is not None: - prefers_dark = bool(settings.get_property("gtk-application-prefer-dark-theme")) - theme_name = str(settings.get_property("gtk-theme-name") or "").lower() - is_dark = prefers_dark or ("dark" in theme_name) - return "#F5F7FA" if is_dark else "#111318" + return _toolbar_icon_variant_from_settings(settings) + + def _set_tool_button_icon( + self, + button: Gtk.ToggleButton, + image: Gtk.Image, + icon_name: str, + fallback_label: str, + ) -> None: + if self._load_tool_icon(image, icon_name): + if button.get_child() is not image: + button.set_child(image) + return + button.set_child(Gtk.Label(label=fallback_label)) + + def _load_tool_icon(self, image: Gtk.Image, icon_name: str) -> bool: + variant = self._toolbar_icon_variant() + for icon_path in _tool_icon_candidates(self._icon_dir, icon_name, variant): + if icon_path.suffix == ".png": + if self._load_png_icon(image, icon_path): + return True + continue + if icon_path.suffix == ".svg" and self._load_svg_icon(image, icon_path): + return True + image.set_from_icon_name(None) + return False + + def _load_png_icon(self, image: Gtk.Image, icon_path: Path) -> bool: + if not icon_path.is_file(): + return False + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)) + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + image.set_from_paintable(texture) + return True + except Exception: + image.set_from_icon_name(None) + return False def _on_theme_changed(self, *_args) -> None: self._refresh_tool_icons() @@ -414,8 +468,8 @@ def _load_svg_icon(self, image: Gtk.Image, icon_path: Path) -> bool: return False def _refresh_tool_icons(self) -> None: - for image, icon_path, _fallback in getattr(self, "_tool_icon_bindings", []): - self._load_svg_icon(image, icon_path) + for button, image, icon_name, fallback_label in getattr(self, "_tool_icon_bindings", []): + self._set_tool_button_icon(button, image, icon_name, fallback_label) def _build_canvas(self) -> None: self._drawing_area = Gtk.DrawingArea() diff --git a/tests/test_editor_logic.py b/tests/test_editor_logic.py index c19c5fe..fb22fd9 100644 --- a/tests/test_editor_logic.py +++ b/tests/test_editor_logic.py @@ -362,6 +362,61 @@ def set_tooltip_text(self, text): assert button.rgba.parsed == "red" assert button.tooltip == "Annotation color" + +def test_toolbar_theme_helpers_and_icon_candidates(tmp_path): + class Settings: + def __init__(self, prefers_dark, theme_name): + self.prefers_dark = prefers_dark + self.theme_name = theme_name + + def get_property(self, key): + if key == "gtk-application-prefer-dark-theme": + return self.prefers_dark + if key == "gtk-theme-name": + return self.theme_name + return None + + assert editor._toolbar_icon_variant_from_settings(Settings(False, "Adwaita")) == "light" + assert editor._toolbar_icon_variant_from_settings(Settings(False, "Yaru-dark")) == "dark" + assert editor._toolbar_icon_variant_from_settings(Settings(True, "Adwaita")) == "dark" + assert editor._toolbar_icon_color_from_variant("light") == "#111318" + assert editor._toolbar_icon_color_from_variant("dark") == "#F5F7FA" + + candidates = editor._tool_icon_candidates(tmp_path, "tool-select", "dark") + assert candidates == [ + tmp_path / "tool-select-dark.png", + tmp_path / "tool-select.png", + tmp_path / "tool-select.svg", + ] + + +def test_load_tool_icon_prefers_png_then_falls_back_to_svg(): + image = SimpleNamespace(set_from_icon_name=lambda *_: None) + + calls = [] + self_png = SimpleNamespace( + _icon_dir=Path("/unused"), + _toolbar_icon_variant=lambda: "dark", + _load_png_icon=lambda _image, path: calls.append(("png", path.name)) or True, + _load_svg_icon=lambda _image, path: calls.append(("svg", path.name)) or True, + ) + assert editor.AnnotationEditor._load_tool_icon(self_png, image, "tool-select") is True + assert calls == [("png", "tool-select-dark.png")] + + calls.clear() + self_svg = SimpleNamespace( + _icon_dir=Path("/unused"), + _toolbar_icon_variant=lambda: "dark", + _load_png_icon=lambda _image, path: calls.append(("png", path.name)) or False, + _load_svg_icon=lambda _image, path: calls.append(("svg", path.name)) or True, + ) + assert editor.AnnotationEditor._load_tool_icon(self_svg, image, "tool-select") is True + assert calls == [ + ("png", "tool-select-dark.png"), + ("png", "tool-select.png"), + ("svg", "tool-select.svg"), + ] + def test_editor_draw_drag_click_and_keys(monkeypatch): self = FakeEditorSelf() self._surface = FakeSurface(100, 100) From 911f1a89f022607bc2c5dd99c784a1d453e6afdc Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 21:36:52 -0300 Subject: [PATCH 09/15] feat: improve GNOME shortcut handling by disabling native conflicts; enhance related tests --- README.md | 4 +- src/screenux_hotkey.py | 45 +++++++++++++++++++ tests/test_hotkey_backend_gnome.py | 70 +++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50ccac7..5482bbc 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ Save folder behavior: Global hotkey behavior: - Default shortcut is `Ctrl+Print`. -- If it is already taken, Screenux falls back to `Ctrl+Shift+S` (then `Ctrl+Alt+S`, then `Alt+Shift+S`, then `Super+Shift+S`). -- On GNOME/Linux, native screenshot bindings (including clipboard variants like `Ctrl+Print`) are treated as conflicts, so Screenux automatically falls back instead of silently failing. +- On GNOME/Linux, when your selected shortcut matches a native screenshot binding (including clipboard variants like `Ctrl+Print`), Screenux first tries to disable that native binding so your shortcut can be used directly. +- If the shortcut is still unavailable, Screenux falls back to `Ctrl+Shift+S` (then `Ctrl+Alt+S`, then `Alt+Shift+S`, then `Super+Shift+S`). - On GNOME, the shortcut is persisted as a GNOME custom shortcut and works when the app is closed. - On non-GNOME desktops, global shortcut support is best-effort while the app is running. - Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index 98b4f30..81336a5 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -319,6 +319,29 @@ def collect_gnome_taken_shortcuts(runner: Runner = _default_runner, exclude_path return taken +def _native_shortcut_conflicts(shortcut: str, runner: Runner) -> list[tuple[str, str]]: + conflicts: list[tuple[str, str]] = [] + for schema, key in _NATIVE_SHORTCUT_KEYS: + if not _schema_exists(schema, runner): + continue + parsed = parse_gsettings_binding(_gsettings_get(schema, key, runner) or "") + if parsed == shortcut: + conflicts.append((schema, key)) + return conflicts + + +def _clear_native_shortcut_conflicts(conflicts: list[tuple[str, str]], runner: Runner) -> tuple[list[str], list[str]]: + cleared: list[str] = [] + failed: list[str] = [] + for schema, key in conflicts: + target = f"{schema}:{key}" + if _gsettings_set(schema, key, "[]", runner): + cleared.append(target) + else: + failed.append(target) + return cleared, failed + + def _remove_screenux_shortcut(paths: list[str], runner: Runner) -> None: screenux_path = _find_screenux_custom_path(paths, runner) if screenux_path is None: @@ -360,6 +383,28 @@ def register_gnome_shortcut( preferred = normalize_shortcut(shortcut) taken = collect_gnome_taken_shortcuts(runner=runner, exclude_path=screenux_path) resolved, warning = resolve_shortcut_with_fallback(preferred, lambda candidate: candidate in taken) + if resolved != preferred: + conflicts = _native_shortcut_conflicts(preferred, runner) + if conflicts: + cleared, failed = _clear_native_shortcut_conflicts(conflicts, runner) + _log_telemetry( + "register.reclaim", + preferred=preferred, + conflicts=[f"{schema}:{key}" for schema, key in conflicts], + cleared=cleared, + failed=failed, + ) + if cleared: + refreshed_taken = collect_gnome_taken_shortcuts(runner=runner, exclude_path=screenux_path) + if preferred not in refreshed_taken: + resolved = preferred + if failed: + warning = ( + f"Reclaimed {preferred} by disabling native binding(s): " + f"{', '.join(cleared)}. Some bindings could not be changed: {', '.join(failed)}." + ) + else: + warning = None _log_telemetry("register.resolve", preferred=preferred, resolved=resolved, warning=warning) if resolved is None: diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index 820d222..fcad143 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -167,7 +167,7 @@ def test_collect_gnome_taken_shortcuts_includes_native_clip_bindings(): assert "Ctrl+Print" in taken -def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_conflicts(): +def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_cannot_be_cleared(): calls: list[list[str]] = [] new_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" mapping = { @@ -194,6 +194,7 @@ def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_conflict ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "['Print']\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "set", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip", "[]"): (1, "", "permission denied"), } runner = _make_runner(mapping, calls) @@ -215,6 +216,73 @@ def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_conflict ) +def test_register_gnome_shortcut_reclaims_native_clip_shortcut_when_requested(): + calls: list[list[str]] = [] + new_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), + } + state = {"clip_binding": "['Print']\n"} + + def runner(command: list[str]): + calls.append(command) + key = tuple(command) + if key == ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): + return SimpleNamespace(returncode=0, stdout=state["clip_binding"], stderr="") + if key == ("gsettings", "set", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip", "[]"): + state["clip_binding"] = "[]\n" + return SimpleNamespace(returncode=0, stdout="", stderr="") + code, stdout, stderr = mapping.get(key, (0, "", "")) + return SimpleNamespace(returncode=code, stdout=stdout, stderr=stderr) + + result = hotkey.register_gnome_shortcut("Ctrl+Print", runner=runner) + + assert result.shortcut == "Ctrl+Print" + assert any( + command + == [ + "gsettings", + "set", + hotkey.GNOME_MEDIA_SCHEMA, + "screenshot-clip", + "[]", + ] + for command in calls + ) + assert any( + command + == [ + "gsettings", + "set", + f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", + "binding", + "['Print']", + ] + for command in calls + ) + + def test_register_gnome_shortcut_emits_telemetry_logs(caplog): calls: list[list[str]] = [] mapping = { From 15e0994fd81ef247f908010523c636a7fa75732c Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 21:45:27 -0300 Subject: [PATCH 10/15] feat: enhance capture command resolution and validation; improve related tests for GNOME shortcuts --- src/screenux_hotkey.py | 42 ++++++++++++++++++++++++++++-- tests/test_hotkey_backend_gnome.py | 37 +++++++++++++++++--------- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index 81336a5..84c2a7d 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -3,7 +3,10 @@ import logging import os import re +import shlex +import shutil import subprocess # nosec B404 - required for trusted local command invocation. +import sys from dataclasses import dataclass from types import SimpleNamespace from typing import Callable @@ -87,6 +90,39 @@ def _log_telemetry(event: str, **fields: object) -> None: LOGGER.info("hotkey.%s", event) +def _resolve_capture_command() -> str: + configured = os.environ.get("SCREENUX_CAPTURE_COMMAND", "").strip() + if configured: + return configured + + executable = shutil.which("screenux-screenshot") + if executable: + return f"{shlex.quote(executable)} --capture" + + argv0 = (sys.argv[0] or "").strip() + if argv0: + resolved = argv0 if os.path.isabs(argv0) else (shutil.which(argv0) or argv0) + if os.path.basename(resolved) == "screenux-screenshot": + return f"{shlex.quote(resolved)} --capture" + + return SCREENUX_CAPTURE_COMMAND + + +def _is_screenux_capture_command(command: str) -> bool: + text = command.strip() + if not text: + return False + if text == SCREENUX_CAPTURE_COMMAND: + return True + try: + parts = shlex.split(text) + except ValueError: + return False + if len(parts) < 2 or parts[-1] != "--capture": + return False + return any(os.path.basename(part) == "screenux-screenshot" for part in parts[:-1]) + + def _normalize_key_token(token: str) -> str: text = token.strip() if not text: @@ -285,7 +321,7 @@ def _find_screenux_custom_path(paths: list[str], runner: Runner) -> str | None: schema = f"{GNOME_CUSTOM_SCHEMA}:{path}" current_name = _strip_single_quotes(_gsettings_get(schema, "name", runner)) current_command = _strip_single_quotes(_gsettings_get(schema, "command", runner)) - if current_name == SCREENUX_SHORTCUT_NAME or current_command == SCREENUX_CAPTURE_COMMAND: + if current_name == SCREENUX_SHORTCUT_NAME or _is_screenux_capture_command(current_command): return path return None @@ -418,13 +454,15 @@ def register_gnome_shortcut( _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(paths), runner) target_schema = f"{GNOME_CUSTOM_SCHEMA}:{target_path}" + capture_command = _resolve_capture_command() name_set = _gsettings_set(target_schema, "name", SCREENUX_SHORTCUT_NAME, runner) - command_set = _gsettings_set(target_schema, "command", SCREENUX_CAPTURE_COMMAND, runner) + command_set = _gsettings_set(target_schema, "command", capture_command, runner) binding_value = shortcut_to_gsettings_binding(resolved) binding_set = _gsettings_set(target_schema, "binding", binding_value, runner) _log_telemetry( "register.persisted", binding=binding_value, + command=capture_command, command_set=command_set, name_set=name_set, path=target_path, diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index fcad143..423da25 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -112,13 +112,15 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting assert result.warning is not None assert any( command + and command[:4] == [ "gsettings", "set", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", "command", - hotkey.SCREENUX_CAPTURE_COMMAND, ] + and "screenux-screenshot" in command[4] + and command[4].endswith(" --capture") for command in calls ) assert any( @@ -270,17 +272,28 @@ def runner(command: list[str]): ] for command in calls ) - assert any( - command - == [ - "gsettings", - "set", - f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", - "binding", - "['Print']", - ] - for command in calls - ) + + +def test_find_screenux_custom_path_matches_absolute_capture_command(): + calls: list[list[str]] = [] + custom_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + mapping = { + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{custom_path}", "name"): ( + 0, + "'Other Name'\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{custom_path}", "command"): ( + 0, + "'/usr/bin/screenux-screenshot --capture'\n", + "", + ), + } + runner = _make_runner(mapping, calls) + + found = hotkey._find_screenux_custom_path([custom_path], runner) # noqa: SLF001 - internal helper coverage + + assert found == custom_path def test_register_gnome_shortcut_emits_telemetry_logs(caplog): From 8c0e23ef55565a11135913998dee24b7d8bac8fd Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 21:51:29 -0300 Subject: [PATCH 11/15] feat: update GNOME shortcut handling to use accelerator strings; improve related tests for consistency --- scripts/install/install-screenux.sh | 12 ++++++------ scripts/install/lib/gnome_shortcuts.sh | 4 ++-- src/screenux_hotkey.py | 2 +- tests/test_hotkey_backend_gnome.py | 14 +++++++------- tests/test_install_uninstall_scripts.py | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index d7eaf73..af91a7c 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -8,18 +8,18 @@ source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" DEFAULT_BUNDLE_NAME="screenux-screenshot.flatpak" -PRINT_KEYBINDING="['Print']" +PRINT_KEYBINDING="Print" usage() { cat << 'EOF' Usage: - ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] [--shortcut "['s']"] + ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] [--shortcut "s"] ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] --print-screen Options: --bundle PATH Flatpak bundle path. If omitted and app is not installed, tries ./screenux-screenshot.flatpak - --shortcut BINDING Configure GNOME shortcut with gsettings list syntax - --print-screen Shortcut preset for ['Print'] + disable native GNOME Print bindings + --shortcut BINDING Configure GNOME shortcut accelerator string (for example: s) + --print-screen Shortcut preset for Print + disable native GNOME Print bindings --no-shortcut Skip shortcut setup (default) -h, --help Show this help @@ -27,7 +27,7 @@ Examples: ./install-screenux.sh ./install-screenux.sh --bundle ./screenux-screenshot.flatpak ./install-screenux.sh --bundle ./screenux-screenshot.flatpak --print-screen - ./install-screenux.sh --bundle ./screenux-screenshot.flatpak --shortcut "['s']" + ./install-screenux.sh --bundle ./screenux-screenshot.flatpak --shortcut "s" EOF } @@ -127,7 +127,7 @@ main() { ;; --shortcut) shift - (($# > 0)) || fail "--shortcut requires a binding list value" + (($# > 0)) || fail "--shortcut requires an accelerator string value" [[ "${shortcut_option_seen}" == "false" ]] || fail "Only one of --shortcut, --print-screen, or --no-shortcut can be used." shortcut_option_seen="true" shortcut_mode="custom" diff --git a/scripts/install/lib/gnome_shortcuts.sh b/scripts/install/lib/gnome_shortcuts.sh index 807c03f..6037331 100644 --- a/scripts/install/lib/gnome_shortcuts.sh +++ b/scripts/install/lib/gnome_shortcuts.sh @@ -168,8 +168,8 @@ configure_gnome_shortcut() { return 0 fi - if [[ "${binding}" != \[*\] ]]; then - fail "Keybinding must be a gsettings list, e.g. \"['Print']\" or \"['s']\"" + if [[ "${binding}" == \[*\] ]]; then + fail "Keybinding must be an accelerator string, e.g. \"Print\" or \"s\"" fi echo "==> Configuring GNOME custom shortcut: ${binding}" diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index 84c2a7d..e1df8a8 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -269,7 +269,7 @@ def shortcut_to_gsettings_binding(shortcut: str) -> str: modifiers = parts[:-1] modifier_prefix = "".join(_GSETTINGS_MODIFIER[item] for item in modifiers) key_token = key.lower() if len(key) == 1 else key - return f"['{modifier_prefix}{key_token}']" + return f"{modifier_prefix}{key_token}" def _schema_exists(schema: str, runner: Runner) -> bool: diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index 423da25..89a8281 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -41,7 +41,7 @@ def test_collect_gnome_taken_shortcuts_parses_custom_and_native_bindings(): ), ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{custom_path}", "binding"): ( 0, - "['Print']\n", + "'Print'\n", "", ), ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "['Print']\n", ""), @@ -94,7 +94,7 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting ), ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "binding"): ( 0, - "['Print']\n", + "'Print'\n", "", ), ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), @@ -130,7 +130,7 @@ def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting "set", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", "binding", - "['s']", + "s", ] for command in calls ) @@ -158,7 +158,7 @@ def test_collect_gnome_taken_shortcuts_includes_native_clip_bindings(): ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), - ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "['Print']\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "'Print'\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), } @@ -193,7 +193,7 @@ def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_cannot_b ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), - ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "['Print']\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip"): (0, "'Print'\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), ("gsettings", "set", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip", "[]"): (1, "", "permission denied"), @@ -212,7 +212,7 @@ def test_register_gnome_shortcut_uses_fallback_when_native_clip_binding_cannot_b "set", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", "binding", - "['s']", + "s", ] for command in calls ) @@ -245,7 +245,7 @@ def test_register_gnome_shortcut_reclaims_native_clip_shortcut_when_requested(): ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot-clip"): (0, "[]\n", ""), ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot-clip"): (0, "[]\n", ""), } - state = {"clip_binding": "['Print']\n"} + state = {"clip_binding": "'Print'\n"} def runner(command: list[str]): calls.append(command) diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py index 46950c0..20c29ca 100644 --- a/tests/test_install_uninstall_scripts.py +++ b/tests/test_install_uninstall_scripts.py @@ -214,7 +214,7 @@ def test_installer_can_configure_print_screen_shortcut(self): log, ) self.assertIn( - "gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ binding ['Print']", + "gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ binding Print", log, ) From 8d08d878357328835bf2c56014ec4e156e067038 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 21:55:56 -0300 Subject: [PATCH 12/15] feat: enhance GNOME shortcut handling by restoring native Print bindings on disable; improve related tests --- README.md | 2 +- src/screenux_hotkey.py | 54 +++++++++++++++++++++- tests/test_hotkey_backend_gnome.py | 71 +++++++++++++++++++++++++++++ tests/test_hotkey_config.py | 1 + tests/test_window_and_screenshot.py | 10 ++++ 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5482bbc..1423194 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Global hotkey behavior: - Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). - While the shortcut field is focused, press the key combo and Screenux builds it automatically (example: `Ctrl + S`). - You can apply with `Enter` or `Apply`. -- You can return to default with `Default`, or clear/disable with `Clear`. +- You can return to default with `Default`, or clear/disable with `Clear` (on GNOME this also restores native Print screenshot bindings). - Set `SCREENUX_LOG_LEVEL=INFO` to emit hotkey telemetry to stderr (registration resolution and `--capture` detection/handling) when debugging shortcut issues. ## 🖼️ UI example diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py index e1df8a8..a000042 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -47,6 +47,8 @@ _NATIVE_SHORTCUT_KEYS = ( (GNOME_SHELL_SCHEMA, "show-screenshot"), (GNOME_SHELL_SCHEMA, "show-screenshot-ui"), + (GNOME_SHELL_SCHEMA, "screenshot"), + (GNOME_SHELL_SCHEMA, "screenshot-window"), (GNOME_SHELL_SCHEMA, "show-screen-recording-ui"), (GNOME_MEDIA_SCHEMA, "screenshot"), (GNOME_MEDIA_SCHEMA, "window-screenshot"), @@ -56,6 +58,19 @@ (GNOME_MEDIA_SCHEMA, "area-screenshot-clip"), ) +_NATIVE_PRINT_RESET_KEYS = ( + (GNOME_SHELL_SCHEMA, "show-screenshot"), + (GNOME_SHELL_SCHEMA, "show-screenshot-ui"), + (GNOME_SHELL_SCHEMA, "screenshot"), + (GNOME_SHELL_SCHEMA, "screenshot-window"), + (GNOME_MEDIA_SCHEMA, "screenshot"), + (GNOME_MEDIA_SCHEMA, "window-screenshot"), + (GNOME_MEDIA_SCHEMA, "area-screenshot"), + (GNOME_MEDIA_SCHEMA, "screenshot-clip"), + (GNOME_MEDIA_SCHEMA, "window-screenshot-clip"), + (GNOME_MEDIA_SCHEMA, "area-screenshot-clip"), +) + @dataclass(frozen=True) class HotkeyRegistrationResult: @@ -134,7 +149,7 @@ def _normalize_key_token(token: str) -> str: if upper == "PRINT": return "Print" compact = re.sub(r"[\s_-]+", "", upper) - if compact in {"PRINTSCREEN", "PRTSC", "PRTSCN"}: + if compact in {"PRINTSCREEN", "PRTSC", "PRTSCN", "SYSREQ"}: return "Print" if upper.startswith("F") and upper[1:].isdigit(): return upper @@ -291,6 +306,11 @@ def _gsettings_set(schema: str, key: str, value: str, runner: Runner) -> bool: return _success(result) +def _gsettings_reset(schema: str, key: str, runner: Runner) -> bool: + result = _run(["gsettings", "reset", schema, key], runner) + return _success(result) + + def _gsettings_available(runner: Runner) -> bool: result = _run(["gsettings", "--version"], runner) return _success(result) @@ -300,6 +320,13 @@ def _build_gsettings_list(paths: list[str]) -> str: return "[" + ", ".join(f"'{path}'" for path in paths) + "]" +def _key_exists(schema: str, key: str, runner: Runner) -> bool: + result = _run(["gsettings", "list-keys", schema], runner) + if not _success(result): + return False + return key in _stdout(result).splitlines() + + def _custom_paths(runner: Runner) -> list[str]: raw = _gsettings_get(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, runner) if raw is None: @@ -386,6 +413,27 @@ def _remove_screenux_shortcut(paths: list[str], runner: Runner) -> None: _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(updated_paths), runner) +def _remove_screenux_shortcut_entry(paths: list[str], runner: Runner) -> bool: + screenux_path = _find_screenux_custom_path(paths, runner) + if screenux_path is None: + return False + updated_paths = [path for path in paths if path != screenux_path] + _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(updated_paths), runner) + return True + + +def _restore_native_print_bindings(runner: Runner) -> list[str]: + restored: list[str] = [] + for schema, key in _NATIVE_PRINT_RESET_KEYS: + if not _schema_exists(schema, runner): + continue + if not _key_exists(schema, key, runner): + continue + if _gsettings_reset(schema, key, runner): + restored.append(f"{schema}:{key}") + return restored + + def _next_available_custom_path(paths: list[str]) -> str: index = 0 existing = set(paths) @@ -412,7 +460,9 @@ def register_gnome_shortcut( screenux_path = _find_screenux_custom_path(paths, runner) if shortcut is None: - _remove_screenux_shortcut(paths, runner) + removed = _remove_screenux_shortcut_entry(paths, runner) + restored_native = _restore_native_print_bindings(runner) if removed else [] + _log_telemetry("register.restore-native", restored=restored_native) _log_telemetry("register.disabled") return HotkeyRegistrationResult(None, None) diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index 89a8281..5a02968 100644 --- a/tests/test_hotkey_backend_gnome.py +++ b/tests/test_hotkey_backend_gnome.py @@ -332,3 +332,74 @@ def test_register_gnome_shortcut_emits_telemetry_logs(caplog): assert "hotkey.register.start" in caplog.text assert "hotkey.register.resolve" in caplog.text assert "hotkey.register.persisted" in caplog.text + + +def test_register_gnome_shortcut_disable_restores_native_print_bindings(): + calls: list[list[str]] = [] + existing_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "list-keys", hotkey.GNOME_SHELL_SCHEMA): ( + 0, + "\n".join(["show-screenshot-ui", "screenshot", "screenshot-window"]) + "\n", + "", + ), + ("gsettings", "list-keys", hotkey.GNOME_MEDIA_SCHEMA): ( + 0, + "\n".join(["custom-keybindings", "screenshot", "window-screenshot", "area-screenshot"]) + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): ( + 0, + f"['{existing_path}']\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "name"): ( + 0, + "'Screenux Screenshot'\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "command"): ( + 0, + "'/usr/bin/screenux-screenshot --capture'\n", + "", + ), + } + runner = _make_runner(mapping, calls) + + result = hotkey.register_gnome_shortcut(None, runner=runner) + + assert result.shortcut is None + assert any( + command + == [ + "gsettings", + "set", + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_KEY, + "[]", + ] + for command in calls + ) + assert any( + command + == [ + "gsettings", + "reset", + hotkey.GNOME_SHELL_SCHEMA, + "show-screenshot-ui", + ] + for command in calls + ) diff --git a/tests/test_hotkey_config.py b/tests/test_hotkey_config.py index 04f4520..b62ba0e 100644 --- a/tests/test_hotkey_config.py +++ b/tests/test_hotkey_config.py @@ -49,4 +49,5 @@ def _save(config): def test_normalize_shortcut_accepts_printscreen_aliases_with_modifier_combo(): assert hotkey.normalize_shortcut("ctrl+printscreen") == "Ctrl+Print" assert hotkey.normalize_shortcut("CTRL+PrtScn") == "Ctrl+Print" + assert hotkey.normalize_shortcut("Ctrl+Sys_Req") == "Ctrl+Print" assert hotkey.normalize_shortcut("Shift+Print Screen") == "Shift+Print" diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 5c5079a..05e9a82 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -264,6 +264,16 @@ def test_window_hotkey_capture_builds_shortcut_from_key_event(monkeypatch): assert consumed_modifier is True assert self._hotkey_entry.text == "Ctrl + Shift + S" + consumed_print = window.MainWindow._on_hotkey_entry_key_pressed( + self, + None, + 1111, + 0, + 0, + ) + assert consumed_print is True + assert self._hotkey_entry.text == "Print" + def test_window_hotkey_capture_ignores_unmapped_key(monkeypatch): fake_gdk = SimpleNamespace( From 0ae2c4968b80000433dd0f504591aac31242ab70 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 22:02:10 -0300 Subject: [PATCH 13/15] feat: implement separate screenshot preview window; enhance main app controls and related tests --- README.md | 3 +- src/screenux_window.py | 43 ++++++++++++++++- tests/test_window_and_screenshot.py | 74 ++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1423194..66b7a97 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Default global hotkey: `Ctrl+Print` - Status updates: `Ready`, `Capturing...`, `Saved: `, `Cancelled`, `Failed: ` - Built-in editor for quick annotations (shapes/text) +- Screenshot preview/editor opens in a separate window from the main app controls - Editor toolbar tool icons use bundled light/dark PNG assets with theme-aware selection (fallback when SVG loaders are unavailable in some `.deb` runtimes) - Editor color picker supports older GTK4 runtimes used by some distro `.deb` installs - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) @@ -124,7 +125,7 @@ Preserve app data in `~/.var/app/io.github.rafa.ScreenuxScreenshot`: 1. Launch the app. 2. Click `Take Screenshot`. 3. Confirm or cancel in the system screenshot flow. -4. (Optional) annotate in the editor. +4. (Optional) annotate in the separate preview/editor window. 5. Save and check the status line for the resulting file path. Save folder behavior: diff --git a/src/screenux_window.py b/src/screenux_window.py index f4f4c18..f0bca48 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -171,6 +171,8 @@ def __init__( self._hotkey_value_label: Gtk.Label | None = None self._present_after_capture = False self._did_initial_center = False + self._preview_window: Gtk.Window | None = None + self._closing_preview_programmatically = False self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self._main_box.set_margin_top(16) @@ -397,6 +399,31 @@ def _fail(self, reason: str) -> None: def _show_main_panel(self) -> None: self.set_child(self._main_box) + def _close_preview_window(self) -> None: + preview_window = self._preview_window + if preview_window is None: + return + self._closing_preview_programmatically = True + try: + preview_window.close() + finally: + self._closing_preview_programmatically = False + self._preview_window = None + + def _on_preview_close_request(self, _preview_window: Gtk.Window) -> bool: + self._preview_window = None + if self._closing_preview_programmatically: + return False + button_sensitive = ( + self._button.get_sensitive() + if hasattr(self._button, "get_sensitive") + else getattr(self._button, "sensitive", True) + ) + if not button_sensitive: + self._button.set_sensitive(True) + self._set_status("Ready") + return False + def _on_change_folder(self, _button: Gtk.Button) -> None: dialog = Gtk.FileDialog() dialog.set_title("Choose Screenshot Folder") @@ -505,7 +532,18 @@ def _save_uri(self, source_uri: str) -> None: on_discard=self._on_editor_discard, on_error=self._on_editor_error, ) - self.set_child(editor) + self._close_preview_window() + preview_window = Gtk.Window(title="Screenshot Preview") + app = self.get_application() if hasattr(self, "get_application") else None + if app is not None and hasattr(preview_window, "set_application"): + preview_window.set_application(app) + if hasattr(preview_window, "set_transient_for"): + preview_window.set_transient_for(self) + preview_window.set_default_size(1024, 700) + preview_window.set_child(editor) + preview_window.connect("close-request", self._on_preview_close_request) + self._preview_window = preview_window + preview_window.present() if getattr(self, "_present_after_capture", False): if hasattr(self, "present_with_initial_center"): self.present_with_initial_center() @@ -515,16 +553,19 @@ def _save_uri(self, source_uri: str) -> None: def _on_editor_save(self, saved_path: Path) -> None: self._show_main_panel() + self._close_preview_window() self._button.set_sensitive(True) self._set_status(self._format_status_saved(saved_path)) def _on_editor_discard(self) -> None: self._show_main_panel() + self._close_preview_window() self._button.set_sensitive(True) self._set_status("Ready") def _on_editor_error(self, message: str) -> None: self._show_main_panel() + self._close_preview_window() self._button.set_sensitive(True) self._set_status(f"Failed: {message}") diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 05e9a82..451e1ba 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -24,6 +24,9 @@ def __init__(self): def set_sensitive(self, value): self.sensitive = value + def get_sensitive(self): + return self.sensitive + class DummyEntry: def __init__(self, text=""): @@ -77,11 +80,50 @@ def unpack(self): return self.value +class DummyPreviewWindow: + def __init__(self, title=None): + self.title = title + self.application = None + self.transient_for = None + self.default_size = None + self.child = None + self.present_calls = 0 + self.close_calls = 0 + self._close_request_handler = None + + def set_application(self, app): + self.application = app + + def set_transient_for(self, parent): + self.transient_for = parent + + def set_default_size(self, width, height): + self.default_size = (width, height) + + def set_child(self, child): + self.child = child + + def connect(self, signal, callback): + if signal == "close-request": + self._close_request_handler = callback + return 1 + + def present(self): + self.present_calls += 1 + + def close(self): + self.close_calls += 1 + if self._close_request_handler is not None: + self._close_request_handler(self) + + class FakeWindowSelf: def __init__(self): self._request_counter = 0 self._bus = None self._signal_sub_id = None + self._preview_window = None + self._closing_preview_programmatically = False self._present_after_capture = False self._present_calls = 0 self._button = DummyButton() @@ -98,6 +140,8 @@ def __init__(self): self._finish = lambda status: window.MainWindow._finish(self, status) self._fail = lambda reason: window.MainWindow._fail(self, reason) self._show_main_panel = lambda: window.MainWindow._show_main_panel(self) + self._close_preview_window = lambda: window.MainWindow._close_preview_window(self) + self._on_preview_close_request = lambda preview_window: window.MainWindow._on_preview_close_request(self, preview_window) self._build_handle_token = lambda: window.MainWindow._build_handle_token(self) self._on_portal_response = ( lambda connection, sender_name, object_path, interface_name, signal_name, parameters: window.MainWindow._on_portal_response( @@ -120,6 +164,9 @@ def set_child(self, child): def present(self): self._present_calls += 1 + def get_application(self): + return "app" + def test_window_take_screenshot_public_method(): self = FakeWindowSelf() @@ -396,16 +443,22 @@ def test_window_show_panel_and_editor_callbacks(): window.MainWindow._show_main_panel(self) assert self._set_child_value is self._main_box + self._preview_window = DummyPreviewWindow() window.MainWindow._on_editor_save(self, Path("/tmp/a.png")) assert self._set_child_value is self._main_box + assert self._preview_window is None assert self._button.sensitive is True assert self._status_label.text == "Saved: /tmp/a.png" + self._preview_window = DummyPreviewWindow() window.MainWindow._on_editor_discard(self) + assert self._preview_window is None assert self._status_label.text == "Ready" + self._preview_window = DummyPreviewWindow() window.MainWindow._on_editor_error(self, "save broke") assert self._set_child_value is self._main_box + assert self._preview_window is None assert self._button.sensitive is True assert self._status_label.text == "Failed: save broke" @@ -485,6 +538,8 @@ def test_window_save_uri_success_and_failure(monkeypatch): self = FakeWindowSelf() self._bus = DummyBus() self._signal_sub_id = 3 + fake_gtk = SimpleNamespace(Window=DummyPreviewWindow) + monkeypatch.setattr(window, "Gtk", fake_gtk) marker = object() monkeypatch.setattr(window, "_uri_to_local_path", lambda _uri: Path("/tmp/test x.png")) @@ -496,7 +551,10 @@ def test_window_save_uri_success_and_failure(monkeypatch): ) window.MainWindow._save_uri(self, "file:///tmp/test%20x.png") - assert isinstance(self._set_child_value, dict) + assert self._set_child_value is None + assert isinstance(self._preview_window, DummyPreviewWindow) + assert isinstance(self._preview_window.child, dict) + assert self._preview_window.present_calls == 1 assert self._bus.unsubscribe_calls == [3] self._signal_sub_id = None @@ -509,6 +567,20 @@ def broken(_p): assert self._status_label.text.startswith("Failed: could not load image") +def test_window_preview_close_request_restores_main_ready_state(): + self = FakeWindowSelf() + self._preview_window = DummyPreviewWindow() + self._button.set_sensitive(False) + self._status_label.set_text("Capturing...") + + keep_open = window.MainWindow._on_preview_close_request(self, self._preview_window) + + assert keep_open is False + assert self._preview_window is None + assert self._button.sensitive is True + assert self._status_label.text == "Ready" + + def test_window_save_uri_rejects_invalid_source(monkeypatch): self = FakeWindowSelf() self._bus = DummyBus() From 44dbe1fd49a577204ea0a9e18c19a3f842b1b439 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 22:04:36 -0300 Subject: [PATCH 14/15] feat: update application version to 1.0.0 in Dockerfile and build script; synchronize version in main application --- Dockerfile.deb | 2 +- scripts/build_deb.sh | 2 +- src/screenux_screenshot.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile.deb b/Dockerfile.deb index 05c2c2b..e9216fb 100644 --- a/Dockerfile.deb +++ b/Dockerfile.deb @@ -2,7 +2,7 @@ FROM debian:bookworm-slim ARG DEBIAN_FRONTEND=noninteractive ARG APP_NAME=screenux-screenshot -ARG APP_VERSION=0.1.0 +ARG APP_VERSION=1.0.0 ARG APP_ARCH=amd64 ARG OUT_DIR=/out diff --git a/scripts/build_deb.sh b/scripts/build_deb.sh index 8e064c7..dae6b9f 100755 --- a/scripts/build_deb.sh +++ b/scripts/build_deb.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" APP_NAME="${APP_NAME:-screenux-screenshot}" -APP_VERSION="${APP_VERSION:-0.1.0}" +APP_VERSION="${APP_VERSION:-1.0.0}" APP_ARCH="${APP_ARCH:-amd64}" OUT_DIR="${OUT_DIR:-/out}" APP_ID="${APP_ID:-io.github.rafa.ScreenuxScreenshot}" diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index e4a2878..0cf571d 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -30,7 +30,7 @@ APP_ID = "io.github.rafa.ScreenuxScreenshot" _MAX_CONFIG_SIZE = 64 * 1024 _ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} -APP_VERSION = "0.1.0" +APP_VERSION = "1.0.0" _LOG_LEVEL_ENV = "SCREENUX_LOG_LEVEL" LOGGER = logging.getLogger("screenux.app") From 1df4c8aeb3d77bc19abed739fa16c39bc39970ff Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 22:10:13 -0300 Subject: [PATCH 15/15] feat: update icon install path in CI workflow and tests to match new naming convention --- .github/workflows/ci.yml | 2 +- tests/test_ci_deb_workflow.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 165aac9..0eeda5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,7 +198,7 @@ jobs: grep -Eq '/usr/bin/screenux-screenshot$' artifacts/deb-contents.txt || { echo "::error::Missing /usr/bin install path"; exit 1; } grep -Eq '/usr/share/applications/screenux-screenshot.desktop$' artifacts/deb-contents.txt || { echo "::error::Missing desktop entry install path"; exit 1; } - grep -Eq '/usr/share/icons/hicolor/256x256/apps/screenux-screenshot.png$' artifacts/deb-contents.txt || { echo "::error::Missing icon install path"; exit 1; } + grep -Eq '/usr/share/icons/hicolor/256x256/apps/io.github.rafa.ScreenuxScreenshot.png$' artifacts/deb-contents.txt || { echo "::error::Missing icon install path"; exit 1; } extract_dir="$(mktemp -d)" trap 'rm -rf "${extract_dir}"' EXIT diff --git a/tests/test_ci_deb_workflow.py b/tests/test_ci_deb_workflow.py index f6f96ed..4944a1d 100644 --- a/tests/test_ci_deb_workflow.py +++ b/tests/test_ci_deb_workflow.py @@ -3,6 +3,7 @@ ROOT = Path(__file__).resolve().parents[1] CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +APP_ID = "io.github.rafa.ScreenuxScreenshot" class DebianCiWorkflowTests(unittest.TestCase): @@ -21,6 +22,7 @@ def test_ci_checks_security_integrity_and_performance_for_deb(self): "dpkg-deb --contents \"$deb_file\"", "dpkg-deb -f \"$deb_file\" Package", "sha256sum \"$deb_file\"", + f"/usr/share/icons/hicolor/256x256/apps/{APP_ID}.png", "find \"$extract_dir\" -type f -perm /6000", "find \"$extract_dir\" -type f -perm -0002", "help_startup_ms=",