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:
|
Access applications in multiple ways:
|
||||||
|
|
||||||
- Applications menu: `@MODKEY@` + `F1`
|
- 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)
|
- Common applications have dedicated shortcuts in Ratpoison mode (see table above)
|
||||||
|
|
||||||
The applications menu is organized by categories similar to traditional desktop environments.
|
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.
|
*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.
|
- i3-wm: The i3 window manager.
|
||||||
- jq: For reading i3 state.
|
- jq: For reading i3 state.
|
||||||
- pandoc or markdown: To generate html files.
|
- 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.
|
- python-i3ipc: For i3 integration scripts.
|
||||||
|
- rsync: For installing I38 scripts without generated Python cache files.
|
||||||
- yad: For screen reader accessible dialogs.
|
- yad: For screen reader accessible dialogs.
|
||||||
|
- xdg-utils: For opening files and URLs from the run dialog.
|
||||||
- xdotool: For X11 window interaction.
|
- xdotool: For X11 window interaction.
|
||||||
|
|
||||||
Optional features use these packages when installed:
|
Optional features use these packages when installed:
|
||||||
@@ -35,7 +39,6 @@ Optional features use these packages when installed:
|
|||||||
- pamixer: for the mute-unmute script
|
- pamixer: for the mute-unmute script
|
||||||
- pcmanfm: [optional] Graphical file manager.
|
- pcmanfm: [optional] Graphical file manager.
|
||||||
- playerctl: music controls
|
- playerctl: music controls
|
||||||
- python-gobject: for applications menu.
|
|
||||||
- python-pillow: For OCR
|
- python-pillow: For OCR
|
||||||
- python-pytesseract: For OCR
|
- python-pytesseract: For OCR
|
||||||
- remind: [optional] For reminder notifications, Requires notify-daemon and notify-send for automatic reminders.
|
- 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 ~/.xinitrc and ~/.xprofile.
|
||||||
- -X: Generate ~/.xprofile only.
|
- -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
|
## Ratpoison Mode
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,22 @@ i38CheckoutPath="${PWD}"
|
|||||||
export DIALOGOPTS='--no-lines --visit-items'
|
export DIALOGOPTS='--no-lines --visit-items'
|
||||||
|
|
||||||
# Check to make sure minimum requirements are installed.
|
# 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
|
if ! command -v "$i" &> /dev/null ; then
|
||||||
missing+=("$i")
|
missing+=("$i")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if ! python3 -c 'import i3ipc' &> /dev/null ; then
|
if ! command -v xdg-open &> /dev/null ; then
|
||||||
missing+=("python-i3ipc")
|
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
|
fi
|
||||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||||
echo "Please install the following packages and run this script again:"
|
echo "Please install the following packages and run this script again:"
|
||||||
@@ -286,12 +295,22 @@ terminal_command() {
|
|||||||
local terminalName="${terminalPath##*/}"
|
local terminalName="${terminalPath##*/}"
|
||||||
|
|
||||||
if [[ "$terminalName" == "xterm" ]]; then
|
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
|
else
|
||||||
printf '%s' "$terminalPath"
|
printf '%s' "$terminalPath"
|
||||||
fi
|
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_config() {
|
||||||
# Load existing configuration if available
|
# Load existing configuration if available
|
||||||
if [[ -f "$configFile" ]]; then
|
if [[ -f "$configFile" ]]; then
|
||||||
@@ -476,6 +495,21 @@ apply_screenlock_pin() {
|
|||||||
chmod 600 "$pinFile"
|
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() {
|
update_scripts() {
|
||||||
local existingPinHash=""
|
local existingPinHash=""
|
||||||
local pinFile="${i3Path}/.screenpin"
|
local pinFile="${i3Path}/.screenpin"
|
||||||
@@ -487,7 +521,7 @@ update_scripts() {
|
|||||||
source "$configFile"
|
source "$configFile"
|
||||||
existingPinHash="$screenlockPinHash"
|
existingPinHash="$screenlockPinHash"
|
||||||
fi
|
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_i38_version
|
||||||
write_desktop_shortcuts_template
|
write_desktop_shortcuts_template
|
||||||
if [[ -n "$existingPinHash" ]]; then
|
if [[ -n "$existingPinHash" ]]; then
|
||||||
@@ -827,7 +861,7 @@ if [[ -z "$dex" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ $dex -eq 0 ]] && [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then
|
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
|
fi
|
||||||
if [[ -z "$batteryAlert" ]]; then
|
if [[ -z "$batteryAlert" ]]; then
|
||||||
if command -v acpi &> /dev/null ; then
|
if command -v acpi &> /dev/null ; then
|
||||||
@@ -937,6 +971,7 @@ if [[ $configLoaded -eq 0 || $configChanged -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
terminalExec="$(terminal_command "$terminalEmulator")"
|
terminalExec="$(terminal_command "$terminalEmulator")"
|
||||||
|
screenReaderExec="$(screen_reader_command "$screenReader")"
|
||||||
|
|
||||||
if [[ -d "${i3Path}" ]]; then
|
if [[ -d "${i3Path}" ]]; then
|
||||||
yesno "This will replace your existing configuration at ${i3Path}. Do you want to continue?" || exit 0
|
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_desktop_shortcuts_template
|
||||||
write_i38_xresources
|
write_i38_xresources
|
||||||
# Move scripts into place
|
# 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
|
apply_screenlock_pin
|
||||||
update_personal_customizations
|
update_personal_customizations
|
||||||
|
|
||||||
@@ -989,7 +1024,7 @@ for_window [class="Wine"] floating enable
|
|||||||
bindsym \$mod+Shift+F1 exec $webBrowser ${i3Path}/I38.html
|
bindsym \$mod+Shift+F1 exec $webBrowser ${i3Path}/I38.html
|
||||||
|
|
||||||
# Run dialog
|
# Run dialog
|
||||||
bindsym \$mod+F2 exec ${i3Path}/scripts/run_dialog.sh
|
bindsym \$mod+F2 exec ${i3Path}/scripts/run_dialog.py
|
||||||
|
|
||||||
# Bookmarks dialog
|
# Bookmarks dialog
|
||||||
bindsym \$mod+Control+b exec ${i3Path}/scripts/bookmarks.sh
|
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"
|
bindsym apostrophe exec --no-startup-id ${i3Path}/scripts/window_list.sh, mode "default"
|
||||||
$(if command -v cthulhu &> /dev/null; then
|
$(if command -v cthulhu &> /dev/null; then
|
||||||
echo "# Restart Cthulhu"
|
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)
|
fi)
|
||||||
$(if command -v orca &> /dev/null; then
|
$(if command -v orca &> /dev/null; then
|
||||||
echo "# Restart Orca"
|
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)
|
# 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"
|
bindsym Control+Shift+semicolon exec $i3msg -t run_command restart && spd-say -P important -Cw "I3 restarted.", mode "default"
|
||||||
# Run dialog with exclamation
|
# 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)
|
# 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 \$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'
|
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'
|
echo 'exec --no-startup-id x11bell play -nqV0 synth .1 sq norm -12'
|
||||||
fi
|
fi
|
||||||
if [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then
|
if [[ "$screenReader" != "none" ]] && [[ -n "$screenReader" ]]; then
|
||||||
echo "exec $screenReader"
|
echo "exec $screenReaderExec"
|
||||||
fi
|
fi
|
||||||
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
|
if [ "$selectedReader" = "orca" ]; then
|
||||||
orca &
|
orca &
|
||||||
else
|
else
|
||||||
cthulhu &
|
ORC_CODE=backup cthulhu &
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user