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/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/README.md b/README.md index f220424..66b7a97 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,21 @@ 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 - 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) +- 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%`) - 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 @@ -63,7 +67,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 +81,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: @@ -85,7 +89,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: @@ -121,23 +125,27 @@ 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: -- 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: -- 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`. +- 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). -- You can change or disable the shortcut from the app window (`Apply` / `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` (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/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 new file mode 100644 index 0000000..c5bcb67 Binary files /dev/null and b/assets/icons/io.github.rafa.ScreenuxScreenshot.png differ 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/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 a6122f4..c5bcb67 100644 Binary files a/packaging/linux/screenux-screenshot.png and b/packaging/linux/screenux-screenshot.png differ diff --git a/scripts/build_deb.sh b/scripts/build_deb.sh index e8daf16..dae6b9f 100755 --- a/scripts/build_deb.sh +++ b/scripts/build_deb.sh @@ -5,9 +5,10 @@ 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}" BUILD_WORKDIR="$(mktemp -d -t "${APP_NAME}-deb-XXXXXX")" PKG_ROOT="${BUILD_WORKDIR}/pkg" @@ -66,8 +67,17 @@ install -Dm644 \ "${ROOT_DIR}/packaging/linux/${APP_NAME}.desktop" \ "${PKG_ROOT}/usr/share/applications/${APP_NAME}.desktop" install -Dm644 \ - "${ROOT_DIR}/packaging/linux/${APP_NAME}.png" \ - "${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" + "${ROOT_DIR}/assets/icons/${APP_ID}.png" \ + "${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/${APP_ID}.png" +install -Dm644 \ + "${ROOT_DIR}/assets/icons/${APP_ID}.svg" \ + "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps/${APP_ID}.svg" +install -Dm644 \ + "${ROOT_DIR}/assets/icons/${APP_ID}-light.svg" \ + "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps/${APP_ID}-light.svg" +install -Dm644 \ + "${ROOT_DIR}/assets/icons/${APP_ID}-dark.svg" \ + "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps/${APP_ID}-dark.svg" dpkg-deb --build --root-owner-group "${PKG_ROOT}" "${DEB_FILE}" diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index 43c7d6f..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 } @@ -81,6 +81,7 @@ validate_installation() { [[ -x "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper not executable at ${WRAPPER_PATH}" [[ -f "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry missing at ${DESKTOP_FILE}" [[ -f "${ICON_FILE}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE}" + [[ -f "${ICON_FILE_SVG}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE_SVG}" [[ -f "${ICON_FILE_LIGHT}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE_LIGHT}" [[ -f "${ICON_FILE_DARK}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE_DARK}" } @@ -126,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/common.sh b/scripts/install/lib/common.sh index 86c2a0d..a0be3fd 100644 --- a/scripts/install/lib/common.sh +++ b/scripts/install/lib/common.sh @@ -7,13 +7,16 @@ WRAPPER_DIR="${HOME}/.local/bin" WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" DESKTOP_DIR="${HOME}/.local/share/applications" DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" -ICON_DIR="${HOME}/.local/share/icons/hicolor/scalable/apps" -ICON_FILE="${ICON_DIR}/${APP_ID}.svg" -ICON_FILE_LIGHT="${ICON_DIR}/${APP_ID}-light.svg" -ICON_FILE_DARK="${ICON_DIR}/${APP_ID}-dark.svg" +ICON_PNG_DIR="${HOME}/.local/share/icons/hicolor/256x256/apps" +ICON_SVG_DIR="${HOME}/.local/share/icons/hicolor/scalable/apps" +ICON_FILE="${ICON_PNG_DIR}/${APP_ID}.png" +ICON_FILE_SVG="${ICON_SVG_DIR}/${APP_ID}.svg" +ICON_FILE_LIGHT="${ICON_SVG_DIR}/${APP_ID}-light.svg" +ICON_FILE_DARK="${ICON_SVG_DIR}/${APP_ID}-dark.svg" APP_DATA_DIR="${HOME}/.var/app/${APP_ID}" COMMON_LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -APP_ICON_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}.svg" +APP_ICON_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}.png" +APP_ICON_SVG_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}.svg" APP_ICON_LIGHT_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}-light.svg" APP_ICON_DARK_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}-dark.svg" @@ -61,11 +64,13 @@ EOF create_icon_asset() { [[ -f "${APP_ICON_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_SOURCE}" + [[ -f "${APP_ICON_SVG_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_SVG_SOURCE}" [[ -f "${APP_ICON_LIGHT_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_LIGHT_SOURCE}" [[ -f "${APP_ICON_DARK_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_DARK_SOURCE}" echo "==> 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/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/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/icons/tool-circle-fill-dark.png b/src/icons/tool-circle-fill-dark.png new file mode 100644 index 0000000..641ca26 Binary files /dev/null and b/src/icons/tool-circle-fill-dark.png differ diff --git a/src/icons/tool-circle-fill-light.png b/src/icons/tool-circle-fill-light.png new file mode 100644 index 0000000..08d17f2 Binary files /dev/null and b/src/icons/tool-circle-fill-light.png differ diff --git a/src/icons/tool-circle-outline-dark.png b/src/icons/tool-circle-outline-dark.png new file mode 100644 index 0000000..ce63747 Binary files /dev/null and b/src/icons/tool-circle-outline-dark.png differ diff --git a/src/icons/tool-circle-outline-light.png b/src/icons/tool-circle-outline-light.png new file mode 100644 index 0000000..026a062 Binary files /dev/null and b/src/icons/tool-circle-outline-light.png differ diff --git a/src/icons/tool-rectangle-fill-dark.png b/src/icons/tool-rectangle-fill-dark.png new file mode 100644 index 0000000..25d462c Binary files /dev/null and b/src/icons/tool-rectangle-fill-dark.png differ diff --git a/src/icons/tool-rectangle-fill-light.png b/src/icons/tool-rectangle-fill-light.png new file mode 100644 index 0000000..808f425 Binary files /dev/null and b/src/icons/tool-rectangle-fill-light.png differ diff --git a/src/icons/tool-rectangle-outline-dark.png b/src/icons/tool-rectangle-outline-dark.png new file mode 100644 index 0000000..09491b0 Binary files /dev/null and b/src/icons/tool-rectangle-outline-dark.png differ diff --git a/src/icons/tool-rectangle-outline-light.png b/src/icons/tool-rectangle-outline-light.png new file mode 100644 index 0000000..f6b684d Binary files /dev/null and b/src/icons/tool-rectangle-outline-light.png differ diff --git a/src/icons/tool-select-dark.png b/src/icons/tool-select-dark.png new file mode 100644 index 0000000..ebca0aa Binary files /dev/null and b/src/icons/tool-select-dark.png differ diff --git a/src/icons/tool-select-light.png b/src/icons/tool-select-light.png new file mode 100644 index 0000000..991c61d Binary files /dev/null and b/src/icons/tool-select-light.png differ diff --git a/src/icons/tool-text-dark.png b/src/icons/tool-text-dark.png new file mode 100644 index 0000000..a4f7db7 Binary files /dev/null and b/src/icons/tool-text-dark.png differ diff --git a/src/icons/tool-text-light.png b/src/icons/tool-text-light.png new file mode 100644 index 0000000..967ad01 Binary files /dev/null and b/src/icons/tool-text-light.png differ diff --git a/src/screenux_editor.py b/src/screenux_editor.py index 0ee4c56..b8b87df 100644 --- a/src/screenux_editor.py +++ b/src/screenux_editor.py @@ -27,6 +27,29 @@ _ZOOM_BUTTON_STEP = 1.25 _ZOOM_SCROLL_STEP = 1.15 _ZOOM_PRESETS = (0.33, 0.5, 1.0, 1.33, 2.0, 5.0, 10.0, 15.0, 20.0) +_ICON_COLOR_LIGHT = "#111318" +_ICON_COLOR_DARK = "#F5F7FA" + + +def _toolbar_icon_variant_from_settings(settings: Any | None) -> 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): @@ -143,6 +166,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 @@ -249,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: @@ -259,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 @@ -293,13 +336,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)) @@ -363,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() @@ -396,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() @@ -521,7 +593,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/src/screenux_hotkey.py b/src/screenux_hotkey.py index a19c2ef..a000042 100644 --- a/src/screenux_hotkey.py +++ b/src/screenux_hotkey.py @@ -1,14 +1,18 @@ from __future__ import annotations +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 -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" @@ -21,6 +25,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 = { @@ -42,10 +47,28 @@ _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"), (GNOME_MEDIA_SCHEMA, "area-screenshot"), + (GNOME_MEDIA_SCHEMA, "screenshot-clip"), + (GNOME_MEDIA_SCHEMA, "window-screenshot-clip"), + (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"), ) @@ -74,6 +97,47 @@ 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 _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: @@ -84,6 +148,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", "SYSREQ"}: + return "Print" if upper.startswith("F") and upper[1:].isdigit(): return upper if upper in ("SPACE", "TAB", "ESC", "ESCAPE", "ENTER"): @@ -217,7 +284,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: @@ -239,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) @@ -248,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: @@ -269,15 +348,17 @@ 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 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() @@ -297,9 +378,33 @@ 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 +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: @@ -308,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) @@ -322,24 +448,54 @@ 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) 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) 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: _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) @@ -348,9 +504,21 @@ 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) + capture_command = _resolve_capture_command() + name_set = _gsettings_set(target_schema, "name", SCREENUX_SHORTCUT_NAME, 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, + resolved=resolved, + binding_set=binding_set, + ) return HotkeyRegistrationResult(resolved, warning) diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 9e204d4..0cf571d 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 @@ -27,11 +28,11 @@ 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" +APP_VERSION = "1.0.0" +_LOG_LEVEL_ENV = "SCREENUX_LOG_LEVEL" +LOGGER = logging.getLogger("screenux.app") def _print_help() -> None: @@ -49,20 +50,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: @@ -148,14 +137,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() @@ -178,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 @@ -216,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 @@ -223,6 +235,13 @@ 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 if window is None: window = MainWindow( @@ -240,9 +259,16 @@ 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) - window.present() - if self._auto_capture_pending: - self._auto_capture_pending = False + 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"): + window.present_with_initial_center() + 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 @@ -251,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 @@ -263,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/src/screenux_window.py b/src/screenux_window.py index 9a0ecae..f0bca48 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -8,15 +8,95 @@ 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, normalize_shortcut PORTAL_DEST = "org.freedesktop.portal.Desktop" 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: + 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: @@ -74,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 @@ -88,6 +169,10 @@ 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._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) @@ -144,14 +229,22 @@ 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) + 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") apply_btn.connect("clicked", self._on_hotkey_apply) edit_row.append(apply_btn) - disable_btn = Gtk.Button(label="Disable") + 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="Clear") disable_btn.connect("clicked", self._on_hotkey_disable) edit_row.append(disable_btn) @@ -165,13 +258,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 @@ -182,7 +275,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) @@ -191,12 +284,92 @@ 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) + + 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 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 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)}" @@ -213,6 +386,12 @@ 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): + 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: self._finish(f"Failed: {reason}") @@ -220,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") @@ -328,20 +532,40 @@ 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() + else: + self.present() + self._present_after_capture = False 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_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=", 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_editor_logic.py b/tests/test_editor_logic.py index 195fdd9..fb22fd9 100644 --- a/tests/test_editor_logic.py +++ b/tests/test_editor_logic.py @@ -282,6 +282,141 @@ 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_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) diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py index e79a31e..5a02968 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,19 +106,21 @@ 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 + 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( @@ -128,7 +130,276 @@ 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 + ) + + +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_cannot_be_cleared(): + 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", ""), + ("gsettings", "set", hotkey.GNOME_MEDIA_SCHEMA, "screenshot-clip", "[]"): (1, "", "permission denied"), + } + 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_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 + ) + + +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): + 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 + + +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 a2c544a..b62ba0e 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(): @@ -44,3 +44,10 @@ 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("Ctrl+Sys_Req") == "Ctrl+Print" + assert hotkey.normalize_shortcut("Shift+Print Screen") == "Shift+Print" 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..20c29ca 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()) @@ -212,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, ) @@ -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_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): 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..451e1ba 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -24,6 +24,32 @@ def __init__(self): def set_sensitive(self, value): self.sensitive = value + def get_sensitive(self): + return self.sensitive + + +class DummyEntry: + def __init__(self, text=""): + self.text = text + + def get_text(self): + return self.text + + def set_text(self, text): + self.text = text + + def set_position(self, _position): + return None + + +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"): @@ -54,11 +80,52 @@ 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() self._status_label = DummyLabel() self._main_box = object() @@ -73,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( @@ -92,6 +161,12 @@ def __init__(self): def set_child(self, child): self._set_child_value = child + def present(self): + self._present_calls += 1 + + def get_application(self): + return "app" + def test_window_take_screenshot_public_method(): self = FakeWindowSelf() @@ -103,6 +178,193 @@ 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( + _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_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( + _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"] + + +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" + + 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( + 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 @@ -119,6 +381,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") @@ -164,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" @@ -253,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")) @@ -264,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 @@ -277,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() @@ -460,6 +764,71 @@ 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 + + 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