From e5bd73213d925f34abb63fae72c83f2de760baa0 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 1 Jun 2026 16:44:15 -0400 Subject: [PATCH] Brand new run dialogue. This hasn't been updated all that much since the days of Strychnine. It's time for some modernizations. I think it's much improved, but if y'all hate it let me know and I'll either fix or revert it. --- .githooks/pre-commit | 21 ++ I38.md | 7 +- README.md | 16 +- i38.sh | 57 +++- scripts/run_dialog.py | 586 +++++++++++++++++++++++++++++++++ scripts/run_dialog.sh | 55 ---- scripts/toggle_screenreader.sh | 2 +- 7 files changed, 675 insertions(+), 69 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 scripts/run_dialog.py delete mode 100755 scripts/run_dialog.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..8863b3d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -u + +cacheFiles=() +while IFS= read -r stagedFile; do + case "$stagedFile" in + __pycache__/* | */__pycache__/* | *.pyc | *.pyo | *.pyd) + cacheFiles+=("$stagedFile") + ;; + esac +done < <(git diff --cached --name-only --diff-filter=ACMR) + +if [[ ${#cacheFiles[@]} -eq 0 ]]; then + exit 0 +fi + +printf 'Commit blocked: remove generated Python cache files from the index:\n' >&2 +printf ' %s\n' "${cacheFiles[@]}" >&2 +printf '\nUse git rm --cached -- to unstage forced additions.\n' >&2 +exit 1 diff --git a/I38.md b/I38.md index 6026688..5f8d4fc 100644 --- a/I38.md +++ b/I38.md @@ -258,11 +258,16 @@ This audio feedback provides non-visual confirmation of actions and state change Access applications in multiple ways: - Applications menu: `@MODKEY@` + `F1` -- Run dialog (enter a command): `@MODKEY@` + `F2` or in Ratpoison mode, `!` (exclamation mark) +- Run dialog: `@MODKEY@` + `F2` or in Ratpoison mode, `!` (exclamation mark) - Common applications have dedicated shortcuts in Ratpoison mode (see table above) The applications menu is organized by categories similar to traditional desktop environments. +The run dialog suggests previous entries, installed applications, and commands +from your `PATH` as you type. Enter a path such as `~/Downloads/` to browse its +files and subdirectories. Use the up and down arrows to select a suggestion, +Tab to insert it into the entry, and Enter to open or run it. + *GNOME/MATE comparison:* Instead of clicking on application icons or using a start menu, I38 provides keyboard shortcuts to access applications. diff --git a/README.md b/README.md index 5b280e4..afbff29 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,12 @@ Required for the setup script and generated I38 configuration: - i3-wm: The i3 window manager. - jq: For reading i3 state. - pandoc or markdown: To generate html files. +- python3: For core I38 scripts, including the run dialog. +- python-gobject: For the accessible GTK application menu and run dialog. - python-i3ipc: For i3 integration scripts. +- rsync: For installing I38 scripts without generated Python cache files. - yad: For screen reader accessible dialogs. +- xdg-utils: For opening files and URLs from the run dialog. - xdotool: For X11 window interaction. Optional features use these packages when installed: @@ -35,7 +39,6 @@ Optional features use these packages when installed: - pamixer: for the mute-unmute script - pcmanfm: [optional] Graphical file manager. - playerctl: music controls -- python-gobject: for applications menu. - python-pillow: For OCR - python-pytesseract: For OCR - remind: [optional] For reminder notifications, Requires notify-daemon and notify-send for automatic reminders. @@ -110,6 +113,17 @@ You can apply the same configuration to GTK2 apps. Create or edit ~/.gtkrc-2.0 - -x: Generate ~/.xinitrc and ~/.xprofile. - -X: Generate ~/.xprofile only. +## Development + +Enable the tracked pre-commit hooks once after cloning: + +```bash +git config --local core.hooksPath .githooks +``` + +The pre-commit hook rejects staged Python cache files. The I38 script installer +also excludes and removes Python cache artifacts when copying `scripts/`. + ## Ratpoison Mode diff --git a/i38.sh b/i38.sh index b09f8ba..f5e5002 100755 --- a/i38.sh +++ b/i38.sh @@ -21,13 +21,22 @@ i38CheckoutPath="${PWD}" export DIALOGOPTS='--no-lines --visit-items' # Check to make sure minimum requirements are installed. -for i in dialog jq yad xdotool ; do +declare -a missing=() +for i in dialog jq python3 rsync yad xdotool ; do if ! command -v "$i" &> /dev/null ; then missing+=("$i") fi done -if ! python3 -c 'import i3ipc' &> /dev/null ; then - missing+=("python-i3ipc") +if ! command -v xdg-open &> /dev/null ; then + missing+=("xdg-utils") +fi +if command -v python3 &> /dev/null ; then + if ! python3 -c 'import i3ipc' &> /dev/null ; then + missing+=("python-i3ipc") + fi + if ! python3 -c 'import gi; gi.require_version("Atk", "1.0"); gi.require_version("Gdk", "3.0"); gi.require_version("Gio", "2.0"); gi.require_version("GLib", "2.0"); gi.require_version("Gtk", "3.0"); from gi.repository import Atk, Gdk, Gio, GLib, Gtk' &> /dev/null ; then + missing+=("python-gobject") + fi fi if [[ ${#missing[@]} -gt 0 ]]; then echo "Please install the following packages and run this script again:" @@ -286,12 +295,22 @@ terminal_command() { local terminalName="${terminalPath##*/}" if [[ "$terminalName" == "xterm" ]]; then - printf '%s -T "I38 terminal" -e "fenrir -x"' "$terminalPath" + printf '%s -T "I38 terminal" -e "env ORC_CODE=backup fenrir -x"' "$terminalPath" else printf '%s' "$terminalPath" fi } +screen_reader_command() { + local screenReaderPath="$1" + + if [[ "${screenReaderPath##*/}" == "cthulhu" ]]; then + printf 'env ORC_CODE=backup %s' "$screenReaderPath" + else + printf '%s' "$screenReaderPath" + fi +} + load_config() { # Load existing configuration if available if [[ -f "$configFile" ]]; then @@ -476,6 +495,21 @@ apply_screenlock_pin() { chmod 600 "$pinFile" } +copy_scripts() { + mkdir -p "${i3Path}/scripts" + rsync -rv \ + --exclude='__pycache__/' \ + --exclude='*.pyc' \ + --exclude='*.pyo' \ + --exclude='*.pyd' \ + scripts/ "${i3Path}/scripts/" + find "${i3Path}/scripts" -type f \ + \( -name '*.pyc' -o -name '*.pyo' -o -name '*.pyd' \) \ + -delete + find "${i3Path}/scripts" -depth -type d -name '__pycache__' \ + -exec rm -rf -- {} + +} + update_scripts() { local existingPinHash="" local pinFile="${i3Path}/.screenpin" @@ -487,7 +521,7 @@ update_scripts() { source "$configFile" existingPinHash="$screenlockPinHash" fi - cp -rv scripts/ "${i3Path}/" | dialog --backtitle "I38" --progressbox "Updating scripts..." -1 -1 + copy_scripts | dialog --backtitle "I38" --progressbox "Updating scripts..." -1 -1 write_i38_version write_desktop_shortcuts_template if [[ -n "$existingPinHash" ]]; then @@ -827,7 +861,7 @@ if [[ -z "$dex" ]]; then fi fi if [[ $dex -eq 0 ]] && [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then - dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c "$(command -v "$screenReader")" + dex -t "${XDG_CONFIG_HOME:-${HOME}/.config}/autostart" -c "$(screen_reader_command "$screenReader")" fi if [[ -z "$batteryAlert" ]]; then if command -v acpi &> /dev/null ; then @@ -937,6 +971,7 @@ if [[ $configLoaded -eq 0 || $configChanged -eq 1 ]]; then fi terminalExec="$(terminal_command "$terminalEmulator")" +screenReaderExec="$(screen_reader_command "$screenReader")" if [[ -d "${i3Path}" ]]; then yesno "This will replace your existing configuration at ${i3Path}. Do you want to continue?" || exit 0 @@ -951,7 +986,7 @@ write_i38_version write_desktop_shortcuts_template write_i38_xresources # Move scripts into place -cp -rv scripts/ "${i3Path}/" | dialog --backtitle "I38" --progressbox "Moving scripts into place and writing config..." -1 -1 +copy_scripts | dialog --backtitle "I38" --progressbox "Moving scripts into place and writing config..." -1 -1 apply_screenlock_pin update_personal_customizations @@ -989,7 +1024,7 @@ for_window [class="Wine"] floating enable bindsym \$mod+Shift+F1 exec $webBrowser ${i3Path}/I38.html # Run dialog -bindsym \$mod+F2 exec ${i3Path}/scripts/run_dialog.sh +bindsym \$mod+F2 exec ${i3Path}/scripts/run_dialog.py # Bookmarks dialog bindsym \$mod+Control+b exec ${i3Path}/scripts/bookmarks.sh @@ -1287,7 +1322,7 @@ bindsym Mod1+Shift+r exec --no-startup-id ${i3Path}/scripts/slideshow_record_tog bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default" $(if command -v cthulhu &> /dev/null; then echo "# Restart Cthulhu" - echo "bindsym Shift+c exec $(command -v cthulhu) --replace, mode \"default\"" + echo "bindsym Shift+c exec env ORC_CODE=backup $(command -v cthulhu) --replace, mode \"default\"" fi) $(if command -v orca &> /dev/null; then echo "# Restart Orca" @@ -1302,7 +1337,7 @@ bindsym Control+semicolon exec bash -c '$i3msg -t run_command reload && spd-say # restart i3 inplace (preserves your layout/session, can be used to upgrade i3) bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default" # Run dialog with exclamation -bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.sh, mode "default" +bindsym Shift+exclam exec ${i3Path}/scripts/run_dialog.py, mode "default" # exit i3 (logs you out of your X session) bindsym \$mod+q exec bash -c 'yad --image "dialog-question" --title "I38" --button=yes:0 --button=no:1 --text "You pressed the exit shortcut. Do you really want to exit i3? This will end your X session." && $i3msg -t run_command exit' bindsym Control+\$mod+q exec bash -c 'yad --image "dialog-question" --title "I38" --button=yes:0 --button=no:1 --text "You pressed the exit shortcut. Do you really want to exit i3? This will end your X session." && $i3msg -t run_command exit' @@ -1375,7 +1410,7 @@ else echo 'exec --no-startup-id x11bell play -nqV0 synth .1 sq norm -12' fi if [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then - echo "exec $screenReader" + echo "exec $screenReaderExec" fi fi) diff --git a/scripts/run_dialog.py b/scripts/run_dialog.py new file mode 100755 index 0000000..7bed78e --- /dev/null +++ b/scripts/run_dialog.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 + +# This file is part of I38. +# +# I38 is free software: you can redistribute it and/or modify it under the terms +# of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# I38 is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# I38. If not, see . + +import argparse +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +import gi + +gi.require_version("Atk", "1.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Atk, Gdk, Gio, GLib, Gtk + + +APP_NAME = "I38 Run Dialog" +MAX_HISTORY_ENTRIES = 50 +MAX_RESULTS = 100 +PATH_PREFIXES = ("/", "./", "../", "~/") +RESULT_FEEDBACK_DELAY_MS = 150 +RESULT_SOUND = ["play", "-nqV0", "synth", "0.05", "saw", "300:1200", "vol", "0.20"] + + +@dataclass(frozen=True) +class Result: + kind: str + label: str + detail: str + value: str + app_id: str = "" + + +def state_dir(): + value = os.environ.get("XDG_STATE_HOME") + if value: + return Path(value) / "I38" + return Path.home() / ".local" / "state" / "I38" + + +def history_path(): + return state_dir() / "run-dialog-history" + + +def legacy_history_path(): + return Path(__file__).resolve().parent / ".history" + + +def migrate_history(): + path = history_path() + if path.exists() or not legacy_history_path().is_file(): + return + + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(legacy_history_path(), path) + + +def read_history(): + try: + return [ + line.strip() + for line in history_path().read_text(encoding="utf-8").splitlines() + if line.strip() + ][:MAX_HISTORY_ENTRIES] + except OSError: + return [] + + +def write_history(entry, items): + entry = entry.strip() + if not entry: + return + + updated_items = [entry] + updated_items.extend(item for item in items if item != entry) + path = history_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(updated_items[:MAX_HISTORY_ENTRIES]) + "\n", + encoding="utf-8", + ) + + +def path_commands(): + commands = set() + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory: + continue + try: + for item in os.scandir(directory): + if item.is_file() and os.access(item.path, os.X_OK): + commands.add(item.name) + except OSError: + continue + return sorted(commands, key=str.casefold) + + +def is_path_query(text): + return text.startswith(PATH_PREFIXES) + + +def path_results(text): + if not is_path_query(text): + return [] + + expanded_text = os.path.expanduser(text) + if expanded_text.endswith(os.sep): + directory = Path(expanded_text) + name_prefix = "" + display_parent = text + else: + directory = Path(expanded_text).parent + name_prefix = Path(expanded_text).name + display_parent = text[: len(text) - len(name_prefix)] + + try: + items = list(os.scandir(directory)) + except OSError: + return [] + + results = [] + for item in items: + if not item.name.casefold().startswith(name_prefix.casefold()): + continue + + value = f"{display_parent}{item.name}" + try: + if item.is_dir(): + results.append(Result("directory", f"{item.name}/", "Directory", f"{value}/")) + elif item.is_file() and os.access(item.path, os.X_OK): + results.append(Result("file", item.name, "Executable file", value)) + else: + results.append(Result("file", item.name, "File", value)) + except OSError: + continue + + return sorted( + results, + key=lambda item: (item.kind != "directory", item.label.casefold()), + )[:MAX_RESULTS] + + +def matches(query, value): + return query.casefold() in value.casefold() + + +def application_results(query, applications): + results = [] + for app in applications: + name = app.get_display_name() or app.get_name() + executable = app.get_executable() or "" + if not matches(query, name) and not matches(query, executable): + continue + results.append(Result("application", name, f"Application: {executable}", name, app.get_id() or "")) + + return sorted(results, key=lambda item: item.label.casefold()) + + +def command_results(query, commands): + return [ + Result("command", command, "Command", command) + for command in commands + if matches(query, command) + ] + + +def history_results(query, items): + return [ + Result("history", item, "History", item) + for item in items + if matches(query, item) + ] + + +def merge_results(*groups): + results = [] + seen = set() + for group in groups: + for result in group: + key = (result.kind, result.value, result.app_id) + if key in seen: + continue + seen.add(key) + results.append(result) + if len(results) >= MAX_RESULTS: + return results + return results + + +def normalized_path(text): + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'): + text = text[1:-1] + return Path(os.path.expanduser(text)) + + +def raw_target_kind(text): + text = text.strip() + parsed = urlparse(text) + if parsed.scheme in ("ftp", "http", "https", "file", "mailto"): + return "uri" + if text.startswith("www."): + return "uri" + if text.startswith("man://"): + return "manual" + if normalized_path(text).exists(): + return "path" + return "command" + + +def result_count_message(count): + return f"{count} suggestion" if count == 1 else f"{count} suggestions" + + +def play_result_sound(): + try: + subprocess.Popen( + RESULT_SOUND, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + pass + + +class RunDialog(Gtk.Window): + def __init__(self): + super().__init__(title=APP_NAME) + self.set_default_size(720, 420) + self.set_border_width(12) + self.set_position(Gtk.WindowPosition.CENTER) + self.connect("destroy", Gtk.main_quit) + self.connect("key-press-event", self.on_window_key_press) + + migrate_history() + self.history = read_history() + self.commands = path_commands() + self.applications = { + app.get_id(): app + for app in Gio.AppInfo.get_all() + if app.should_show() and app.get_id() + } + self.results = [] + self.last_feedback_count = None + self.pending_feedback_count = None + self.feedback_timeout_id = None + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add(main_box) + + entry_label = Gtk.Label(label="_Command, application, or file:") + entry_label.set_xalign(0) + main_box.pack_start(entry_label, False, False, 0) + + self.entry = Gtk.Entry() + self.entry.set_placeholder_text("Type a command, application name, or path") + self.entry.connect("changed", self.on_entry_changed) + self.entry.connect("activate", self.on_entry_activate) + self.entry.connect("key-press-event", self.on_entry_key_press) + entry_label.set_mnemonic_widget(self.entry) + main_box.pack_start(self.entry, False, False, 0) + + self.store = Gtk.ListStore(str, str) + self.view = Gtk.TreeView(model=self.store) + self.view.set_headers_visible(True) + self.view.connect("row-activated", self.on_row_activated) + self.view.connect("key-press-event", self.on_view_key_press) + self.view.get_selection().set_mode(Gtk.SelectionMode.SINGLE) + + result_column = Gtk.TreeViewColumn("Result", Gtk.CellRendererText(), text=0) + detail_column = Gtk.TreeViewColumn("Type", Gtk.CellRendererText(), text=1) + self.view.append_column(result_column) + self.view.append_column(detail_column) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.add(self.view) + main_box.pack_start(scrolled, True, True, 0) + + self.status = Gtk.Statusbar() + self.status_context_id = self.status.get_context_id("run-dialog-results") + main_box.pack_start(self.status, False, False, 0) + + button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL) + button_box.set_layout(Gtk.ButtonBoxStyle.END) + open_button = Gtk.Button.new_with_mnemonic("_Open") + open_button.connect("clicked", self.on_open_clicked) + cancel_button = Gtk.Button.new_with_mnemonic("_Cancel") + cancel_button.connect("clicked", lambda _button: self.close()) + button_box.add(cancel_button) + button_box.add(open_button) + main_box.pack_start(button_box, False, False, 0) + + window_accessible = self.get_accessible() + window_accessible.set_name(APP_NAME) + window_accessible.set_description( + "Type to find commands and applications. Type a path to browse files. " + "Use up and down arrows to select a result, Tab to insert a result, " + "and Enter to open it." + ) + self.entry.get_accessible().set_name("Command, application, or file") + self.view.get_accessible().set_name("Run dialog suggestions") + self.view.get_accessible().set_role(Atk.Role.LIST) + self.status.get_accessible().set_name("Run dialog status") + + self.refresh_results() + self.set_status("Type to search. Enter a path such as ~/doomtmp/ to browse files.") + GLib.idle_add(self.entry.grab_focus) + + def refresh_results(self): + query = self.entry.get_text().strip() + if is_path_query(query): + self.results = path_results(query) + else: + self.results = merge_results( + history_results(query, self.history), + application_results(query, self.applications.values()), + command_results(query, self.commands), + ) + + self.store.clear() + for result in self.results: + self.store.append((result.label, result.detail)) + + if self.results: + self.view.get_selection().select_path(0) + self.schedule_result_feedback(len(self.results)) + + def set_status(self, message): + self.status.remove_all(self.status_context_id) + self.status.push(self.status_context_id, message) + + def schedule_result_feedback(self, count): + if self.last_feedback_count is None: + self.last_feedback_count = count + return + if count == self.pending_feedback_count: + return + + self.pending_feedback_count = count + if self.feedback_timeout_id is not None: + GLib.source_remove(self.feedback_timeout_id) + self.feedback_timeout_id = GLib.timeout_add( + RESULT_FEEDBACK_DELAY_MS, + self.announce_result_count, + ) + + def announce_result_count(self): + self.feedback_timeout_id = None + count = self.pending_feedback_count + if count == self.last_feedback_count: + return False + + self.last_feedback_count = count + play_result_sound() + message = result_count_message(count) + self.set_status(message) + status_accessible = self.status.get_accessible() + status_accessible.emit("notification", message, Atk.Live.POLITE) + return False + + def selected_result(self): + model, tree_iter = self.view.get_selection().get_selected() + if tree_iter is None: + return None + return self.results[model.get_path(tree_iter).get_indices()[0]] + + def insert_selected_result(self): + result = self.selected_result() + if result is None: + return + self.entry.set_text(result.value) + self.entry.set_position(-1) + + def activate(self, result=None): + if result is not None and result.kind == "directory": + self.entry.set_text(result.value) + self.entry.set_position(-1) + return + + text = result.value if result is not None else self.entry.get_text().strip() + if not text: + return + + write_history(text, self.history) + if result is not None and result.kind == "application": + self.launch_application(result) + return + self.launch_raw(text) + + def launch_application(self, result): + app = self.applications.get(result.app_id) + if app is None: + self.show_error(f"Application is no longer available: {result.label}") + return + try: + app.launch([], None) + except GLib.Error as error: + self.show_error(f"Could not open {result.label}.\n\n{error.message}") + return + self.close() + + def launch_raw(self, text): + kind = raw_target_kind(text) + if kind == "uri": + target = f"https://{text}" if text.startswith("www.") else text + command = ["xdg-open", target] + elif kind == "manual": + page = text.removeprefix("man://") + if not page: + self.show_error("Enter a manual page after man://") + return + command = [ + "bash", + "-o", + "pipefail", + "-c", + 'man -- "$1" | yad --text-info --show-cursor --button "Close:0" --title "I38 manual page" -', + "_", + page, + ] + elif kind == "path": + path = normalized_path(text) + if path.is_file() and os.access(path, os.X_OK): + command = [str(path)] + else: + command = ["xdg-open", str(path)] + else: + command = ["bash", "-c", text] + + self.start_command(command, text) + + def start_command(self, command, description): + try: + process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + except OSError as error: + self.show_error(f"Could not open {description}.\n\n{error}") + return + + self.hide() + GLib.timeout_add(250, self.check_process, process, description) + + def check_process(self, process, description): + status = process.poll() + if status is None: + Gtk.main_quit() + return False + if status == 0: + Gtk.main_quit() + return False + + error_text = process.stderr.read(2000).strip() + if not error_text: + error_text = "No additional error details were provided." + self.show_all() + self.entry.grab_focus() + self.show_error(f"Could not open {description}.\n\n{error_text}") + return False + + def show_error(self, message): + dialog = Gtk.MessageDialog( + transient_for=self, + flags=Gtk.DialogFlags.MODAL, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.CLOSE, + text=message, + ) + dialog.set_title(APP_NAME) + dialog.run() + dialog.destroy() + + def on_entry_changed(self, _entry): + self.refresh_results() + + def on_entry_activate(self, _entry): + self.activate(self.selected_result()) + + def on_open_clicked(self, _button): + self.activate(self.selected_result()) + + def on_row_activated(self, _view, path, _column): + self.activate(self.results[path.get_indices()[0]]) + + def on_entry_key_press(self, _entry, event): + if event.keyval in (Gdk.KEY_Down, Gdk.KEY_Up): + self.view.grab_focus() + if event.keyval == Gdk.KEY_Up and self.results: + self.view.get_selection().select_path(len(self.results) - 1) + return True + if event.keyval == Gdk.KEY_Tab: + self.insert_selected_result() + return True + return False + + def on_view_key_press(self, _view, event): + if event.keyval == Gdk.KEY_BackSpace: + self.entry.grab_focus() + self.entry.set_position(-1) + self.entry.backspace() + return True + + if event.keyval == Gdk.KEY_Tab: + self.insert_selected_result() + self.entry.grab_focus() + return True + + if event.string and event.string.isprintable(): + self.entry.grab_focus() + self.entry.set_position(-1) + self.entry.insert_text(event.string, -1) + return True + + return False + + def on_window_key_press(self, _window, event): + if event.keyval == Gdk.KEY_Escape: + self.close() + return True + return False + + +def run_self_test(): + test_dir = Path(os.environ["I38_RUN_DIALOG_TEST_DIR"]) + (test_dir / "folder").mkdir(parents=True) + (test_dir / "tool").write_text("#!/bin/sh\n", encoding="utf-8") + (test_dir / "note.txt").write_text("note\n", encoding="utf-8") + (test_dir / "tool").chmod(0o755) + + results = path_results(f"{test_dir}/") + assert [result.label for result in results] == ["folder/", "note.txt", "tool"] + assert results[2].detail == "Executable file" + assert raw_target_kind("mailto:test@example.com") == "uri" + assert raw_target_kind("man://bash") == "manual" + assert raw_target_kind(str(test_dir / "note.txt")) == "path" + assert raw_target_kind('python3 "/tmp/file with spaces.py"') == "command" + assert merge_results( + [Result("history", "firefox", "History", "firefox")], + [Result("command", "firefox", "Command", "firefox")], + )[0].detail == "History" + assert result_count_message(0) == "0 suggestions" + assert result_count_message(1) == "1 suggestion" + assert result_count_message(12) == "12 suggestions" + print("run dialog self-test passed") + + +def main(): + parser = argparse.ArgumentParser(description=APP_NAME) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + run_self_test() + return + + window = RunDialog() + window.show_all() + Gtk.main() + + +if __name__ == "__main__": + main() diff --git a/scripts/run_dialog.sh b/scripts/run_dialog.sh deleted file mode 100755 index 604e29c..0000000 --- a/scripts/run_dialog.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -# This file is part of I38. - -# I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, -# either version 3 of the License, or (at your option) any later version. - -# I38 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR -# PURPOSE. See the GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License along with I38. If not, see . - - -write_history(){ -oldHistory="$(grep -Fvx -- "$txt" "${historyPath}/.history" | head -n 49)" -printf '%s\n%s\n' "$txt" "$oldHistory" | sed 's/^$//g' > "${historyPath}/.history" -} - - -historyPath="$(readlink -f "$0")" -historyPath="${historyPath%/*}" -if ! [[ -d "$historyPath" ]]; then - mkdir -p "$historyPath" -fi - -if [[ -f "${historyPath}/.history" ]]; then - txt="$(yad --entry --editable --title "I38" --text "Execute program or enter file" --button "Open:0" --separator "\n" --rest "${historyPath}/.history")" -else - txt="$(yad --entry --title "I38" --text "Execute program or enter file" --button "Open:0")" -fi -if [[ -z "$txt" ]]; then - exit 0 -fi -if [[ "$txt" =~ ^ftp://|^http://|^https://|^www.* ]]; then - xdg-open "$txt" - write_history - exit 0 -fi -if [[ "$txt" =~ ^mailto://.* ]]; then - xdg-email "$txt" - write_history - exit 0 -fi -if [[ "$txt" =~ ^man://.* ]]; then - eval "${txt/:\/\// }" | yad --text-info --show-cursor --button "Close:0" --title "I38" - - write_history - exit 0 -fi -if command -v "$(echo "$txt" | cut -d " " -f1)" &> /dev/null ; then - eval "$txt" & -else - xdg-open "$txt" & -fi -write_history -exit 0 diff --git a/scripts/toggle_screenreader.sh b/scripts/toggle_screenreader.sh index 4755479..7e0e6ba 100755 --- a/scripts/toggle_screenreader.sh +++ b/scripts/toggle_screenreader.sh @@ -97,7 +97,7 @@ if [ -n "$result" ]; then if [ "$selectedReader" = "orca" ]; then orca & else - cthulhu & + ORC_CODE=backup cthulhu & fi fi