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.
This commit is contained in:
Executable
+21
@@ -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 -- <file> to unstage forced additions.\n' >&2
|
||||
exit 1
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Executable
+586
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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
|
||||
@@ -97,7 +97,7 @@ if [ -n "$result" ]; then
|
||||
if [ "$selectedReader" = "orca" ]; then
|
||||
orca &
|
||||
else
|
||||
cthulhu &
|
||||
ORC_CODE=backup cthulhu &
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user