diff --git a/I38.md b/I38.md
index bbffa22..27058d9 100644
--- a/I38.md
+++ b/I38.md
@@ -345,7 +345,7 @@ This is only available if you chose multiple keyboard layouts during setup.
I38 uses minimal window decorations. Windows do not use the usual title bars with minimize and maximize buttons. Instead, windows fill their available space automatically, and most interactions are performed through keyboard shortcuts.
-`@MODKEY@` + `Control` + `d`: Show desktop icons.
+`@MODKEY@` + `Control` + `d`: Open the I38 Desktop dialog. Use Up and Down to move through items, hold Shift with Up or Down to select multiple items, and press Control+Space to clear the selection. Type to search, press Enter to open the focused item, or press Shift+F10 for a context menu. Escape clears an active search and returns to the list; press it again to close the dialog. Right-click opens the context menu without clearing selected items. Delete permanently removes selected desktop files or folders after confirmation, including the contents of selected folders. Shortcut entries are kept. Use Tab and Shift+Tab to reach the search field and buttons.
## Clipboard Management
diff --git a/README.md b/README.md
index e940281..cc6d20a 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ 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.
+- python3 (3.6 or newer): For core I38 scripts, including the desktop and run dialogs.
+- python-gobject: For the accessible GTK application menu, run dialog, and desktop dialog.
- python-i3ipc: For i3 integration scripts.
- rsync: For installing I38 scripts without generated Python cache files.
- yad: For screen reader accessible dialogs.
@@ -120,6 +120,12 @@ You can apply the same configuration to GTK2 apps. Create or edit ~/.gtkrc-2.0
Default configuration mode uses I38Lock when it is installed and supported by the screen-reader setup. In that case I38 locks on startup and autolocks after 5 minutes when `xprintidle` is available.
+## Desktop Dialog
+
+Press the configured desktop shortcut, Mod+Control+D (Super+Control+D or Alt+Control+D, depending on your setup), to open the I38 Desktop dialog. Use Up and Down to move through items, hold Shift with Up or Down to select multiple items, and press Control+Space to clear the selection. Type to search, and press Enter to open the focused item. Escape clears an active search and returns to the list; press it again to close the dialog. Use Tab and Shift+Tab to reach the search field and buttons. Press Shift+F10 for the context menu; this also opens with the right-click button without clearing selected items. Press Delete to permanently remove selected desktop files or folders after confirmation. Deleting a folder also deletes everything inside it. Shortcut entries are kept.
+
+Quick-launch shortcut entries come from `~/.config/i3/desktop_shortcuts`. They are not deleted from disk by the desktop dialog.
+
## Dependency Helpers
Distro-specific dependency helpers live in `extra/`. On Arch Linux, run:
diff --git a/i38.sh b/i38.sh
index 239c109..2f7691d 100755
--- a/i38.sh
+++ b/i38.sh
@@ -33,6 +33,9 @@ if ! command -v xdg-open &> /dev/null ; then
missing+=("xdg-utils")
fi
if command -v python3 &> /dev/null ; then
+ if ! python3 -c 'import sys; sys.exit(sys.version_info < (3, 6))' &> /dev/null ; then
+ missing+=("python3>=3.6")
+ fi
if ! python3 -c 'import i3ipc' &> /dev/null ; then
missing+=("python-i3ipc")
fi
@@ -1306,8 +1309,8 @@ bindsym \$mod+F4 kill
# Applications menu
bindsym \$mod+F1 exec --no-startup-id "${i3Path}/scripts/menu.py"
-# Desktop icons
-bindsym \$mod+Control+d exec --no-startup-id ${i3Path}/scripts/desktop.sh
+# Desktop dialog
+bindsym \$mod+Control+d exec --no-startup-id ${i3Path}/scripts/desktop.py
# change focus
# Focus previous window (alt+shift+tab)
@@ -1639,7 +1642,7 @@ case "${fileBrowser##*/}" in
;;
esac
echo "exec_always --no-startup-id ${i3Path}/scripts/i38-clipboard.py --daemon"
-echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.sh"
+echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.py"
echo "exec_always --no-startup-id ${i3Path}/scripts/i38-update-check.sh"
if [[ $dex -eq 0 ]]; then
echo '# Start XDG autostart .desktop files using dex. See also'
diff --git a/scripts/desktop.py b/scripts/desktop.py
new file mode 100755
index 0000000..e088b45
--- /dev/null
+++ b/scripts/desktop.py
@@ -0,0 +1,536 @@
+#!/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 os
+import re
+import shlex
+import shutil
+import subprocess
+from collections import namedtuple
+from pathlib import Path
+
+import gi
+
+gi.require_version("Atk", "1.0")
+gi.require_version("Gdk", "3.0")
+gi.require_version("Gtk", "3.0")
+
+from gi.repository import Gdk, Gio, GLib, Gtk
+
+try:
+ gi.require_version("GioUnix", "2.0")
+ from gi.repository import GioUnix
+ DesktopAppInfo = GioUnix.DesktopAppInfo
+except (ImportError, ValueError):
+ DesktopAppInfo = Gio.DesktopAppInfo
+
+
+APP_NAME = "I38 Desktop"
+
+
+DesktopItem = namedtuple("DesktopItem", ("name", "kind", "path", "command"))
+
+
+def build_launch_command(command, workspace):
+ if not workspace or shutil.which("i3-msg") is None:
+ return command
+
+ if re.fullmatch(r"[0-9]+", workspace):
+ return f"i3-msg workspace number {workspace} && {command}"
+
+ return f"i3-msg workspace {shlex.quote(workspace)} && {command}"
+
+
+def read_shortcuts():
+ config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
+ shortcuts_file = config_home / "i3" / "desktop_shortcuts"
+ if not shortcuts_file.is_file():
+ return []
+
+ items = []
+ for raw_line in shortcuts_file.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ fields = line.split("|")
+ if len(fields) < 2:
+ continue
+
+ name = fields[0].strip()
+ command = fields[1].strip()
+ workspace = fields[2].strip() if len(fields) > 2 else ""
+ if not name or not command:
+ continue
+
+ items.append(
+ DesktopItem(
+ name=name,
+ kind="Shortcut",
+ path=None,
+ command=build_launch_command(command, workspace),
+ )
+ )
+ return items
+
+
+def desktop_app_info(path):
+ try:
+ return DesktopAppInfo.new_from_filename(str(path))
+ except (TypeError, GLib.Error):
+ return None
+
+
+def desktop_entry_name(path):
+ app_info = desktop_app_info(path)
+ if app_info is not None:
+ name = app_info.get_display_name()
+ if name:
+ return name
+ return path.name
+
+
+def read_desktop_items():
+ desktop_path = Path.home() / "Desktop"
+ if not desktop_path.is_dir():
+ return []
+
+ directories = sorted(
+ (path for path in desktop_path.iterdir() if path.is_dir()),
+ key=lambda path: path.name.casefold(),
+ )
+ files = sorted(
+ (path for path in desktop_path.iterdir() if not path.is_dir()),
+ key=lambda path: path.name.casefold(),
+ )
+
+ items = []
+ for path in directories:
+ items.append(DesktopItem(path.name, "Folder", path, None))
+
+ for path in files:
+ kind = "Desktop entry" if path.suffix.lower() == ".desktop" else "File"
+ name = desktop_entry_name(path) if kind == "Desktop entry" else path.name
+ items.append(DesktopItem(name, kind, path, None))
+ return items
+
+
+class DesktopWindow(Gtk.Window):
+ def __init__(self, items):
+ super().__init__(title=APP_NAME)
+ self.set_default_size(640, 420)
+ self.set_border_width(12)
+ self.set_position(Gtk.WindowPosition.CENTER)
+ self.set_skip_taskbar_hint(True)
+ self.set_skip_pager_hint(True)
+ self.connect("destroy", Gtk.main_quit)
+ self.connect("focus-out-event", self.on_focus_out)
+
+ self.suppress_focus_close = False
+ self.filter_text = ""
+
+ self.store = Gtk.ListStore(str, str, str, str)
+ self.filter_model = self.store.filter_new()
+ self.filter_model.set_visible_func(self.visible_item)
+
+ main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ self.add(main_box)
+
+ search_label = Gtk.Label(label="_Search desktop items:")
+ search_label.set_xalign(0)
+ main_box.pack_start(search_label, False, False, 0)
+
+ self.search_entry = Gtk.SearchEntry()
+ self.search_entry.set_placeholder_text("Search desktop items")
+ self.search_entry.connect("changed", self.on_search_changed)
+ self.search_entry.connect("key-press-event", self.on_search_key_press)
+ search_label.set_mnemonic_widget(self.search_entry)
+ main_box.pack_start(self.search_entry, False, False, 0)
+
+ self.view = Gtk.TreeView(model=self.filter_model)
+ self.view.set_headers_visible(True)
+ self.view.set_enable_search(False)
+ self.view.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
+ self.view.connect("row-activated", self.on_row_activated)
+ self.view.connect("button-press-event", self.on_tree_button_press)
+ self.view.connect("key-press-event", self.on_tree_key_press)
+
+ name_column = Gtk.TreeViewColumn("Item", Gtk.CellRendererText(), text=0)
+ kind_column = Gtk.TreeViewColumn("Type", Gtk.CellRendererText(), text=1)
+ self.view.append_column(name_column)
+ self.view.append_column(kind_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("desktop-items")
+ 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)
+ delete_button = Gtk.Button.new_with_mnemonic("_Delete")
+ delete_button.connect("clicked", self.on_delete_clicked)
+ close_button = Gtk.Button.new_with_mnemonic("_Close")
+ close_button.connect("clicked", lambda _button: self.close())
+ button_box.add(close_button)
+ button_box.add(delete_button)
+ button_box.add(open_button)
+ main_box.pack_start(button_box, False, False, 0)
+
+ self.get_accessible().set_name(APP_NAME)
+ self.get_accessible().set_description(
+ "Select a desktop item and press Enter to open it. "
+ "Use Shift with Up or Down to select multiple items. "
+ "Press Control+Space to clear the selection. "
+ "Escape clears an active search, then closes the dialog. "
+ "Press Shift+F10 for the context menu or Delete to permanently remove selected items."
+ )
+ self.search_entry.get_accessible().set_name("Search desktop items")
+ self.view.get_accessible().set_name("Desktop items")
+ self.status.get_accessible().set_name("Desktop status")
+
+ self.load_items(items)
+ self.select_first_item()
+ itemCount = len(items)
+ self.set_status(f"{itemCount} {'item' if itemCount == 1 else 'items'} found.")
+ self.show_all()
+ self.view.grab_focus()
+
+ def load_items(self, items):
+ self.store.clear()
+ for item in items:
+ self.store.append(
+ (
+ item.name,
+ item.kind,
+ str(item.path) if item.path is not None else "",
+ item.command or "",
+ )
+ )
+
+ def visible_item(self, model, tree_iter, _data=None):
+ if not self.filter_text:
+ return True
+ name = model.get_value(tree_iter, 0).casefold()
+ kind = model.get_value(tree_iter, 1).casefold()
+ return self.filter_text in name or self.filter_text in kind
+
+ def select_first_item(self):
+ tree_iter = self.filter_model.get_iter_first()
+ if tree_iter is None:
+ self.view.get_selection().unselect_all()
+ return
+ self.view.set_cursor(self.filter_model.get_path(tree_iter), None, False)
+
+ def selected_items(self):
+ model, paths = self.view.get_selection().get_selected_rows()
+ items = []
+ for path in paths:
+ tree_iter = model.get_iter(path)
+ item_path = model.get_value(tree_iter, 2)
+ child_path = self.filter_model.convert_path_to_child_path(path)
+ items.append((
+ child_path.get_indices()[0],
+ path.to_string(),
+ DesktopItem(
+ name=model.get_value(tree_iter, 0),
+ kind=model.get_value(tree_iter, 1),
+ path=Path(item_path) if item_path else None,
+ command=model.get_value(tree_iter, 3) or None,
+ ),
+ ))
+ return items
+
+ def selected_item(self):
+ items = self.selected_items()
+ if not items:
+ return None
+ cursor_path, _column = self.view.get_cursor()
+ if cursor_path is not None:
+ for _index, path, item in items:
+ if path == cursor_path.to_string():
+ return item
+ return items[0][2]
+
+ def set_status(self, message):
+ self.status.remove_all(self.status_context_id)
+ self.status.push(self.status_context_id, message)
+
+ def show_error(self, message):
+ self.suppress_focus_close = True
+ dialog = Gtk.MessageDialog(
+ transient_for=self,
+ flags=Gtk.DialogFlags.MODAL,
+ message_type=Gtk.MessageType.ERROR,
+ buttons=Gtk.ButtonsType.OK,
+ text="I38 Desktop",
+ )
+ dialog.format_secondary_text(message)
+ dialog.run()
+ dialog.destroy()
+ GLib.idle_add(self.allow_focus_close)
+
+ def open_selected(self):
+ item = self.selected_item()
+ if item is None:
+ self.set_status("Select an item to open.")
+ return
+
+ try:
+ if item.command is not None:
+ subprocess.Popen(
+ ["/usr/bin/env", "bash", "-c", item.command],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ elif item.path is not None and item.path.suffix.lower() == ".desktop":
+ app_info = desktop_app_info(item.path)
+ if app_info is None:
+ subprocess.Popen(
+ ["xdg-open", str(item.path)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ elif not app_info.launch([], None):
+ self.show_error(f"Could not open {item.name}.")
+ return
+ elif item.path is not None:
+ subprocess.Popen(
+ ["xdg-open", str(item.path)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except (OSError, GLib.Error) as error:
+ self.show_error(f"Could not open {item.name}.\n\n{error}")
+ return
+
+ self.close()
+
+ def delete_selected(self):
+ selected = self.selected_items()
+ if not selected:
+ self.set_status("Select a desktop item to delete.")
+ return
+ deletable = [(index, item) for index, _path, item in selected if item.path is not None]
+ if not deletable:
+ self.set_status("Shortcut entries are configured in desktop_shortcuts.")
+ return
+
+ self.suppress_focus_close = True
+ dialog = Gtk.MessageDialog(
+ transient_for=self,
+ flags=Gtk.DialogFlags.MODAL,
+ message_type=Gtk.MessageType.QUESTION,
+ buttons=Gtk.ButtonsType.NONE,
+ text="Delete desktop item",
+ )
+ if len(deletable) > 1:
+ folder_selected = any(
+ item.path.is_dir() and not item.path.is_symlink()
+ for _index, item in deletable
+ )
+ message = f"Permanently delete {len(deletable)} selected desktop items"
+ if folder_selected:
+ message += " and everything inside the selected folders"
+ dialog.format_secondary_text(message + "?")
+ elif deletable[0][1].path.is_dir() and not deletable[0][1].path.is_symlink():
+ item = deletable[0][1]
+ dialog.format_secondary_text(
+ f"Permanently delete {item.name} and everything inside it?"
+ )
+ else:
+ dialog.format_secondary_text(f"Permanently delete {deletable[0][1].name}?")
+ dialog.add_buttons(
+ Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
+ "_Delete", Gtk.ResponseType.OK,
+ )
+ dialog.set_default_response(Gtk.ResponseType.CANCEL)
+ response = dialog.run()
+ dialog.destroy()
+ GLib.idle_add(self.allow_focus_close)
+
+ if response != Gtk.ResponseType.OK:
+ return
+
+ deleted = []
+ failures = []
+ for index, item in deletable:
+ try:
+ if item.path.is_dir() and not item.path.is_symlink():
+ shutil.rmtree(item.path)
+ else:
+ item.path.unlink()
+ except OSError as error:
+ failures.append((item.name, error))
+ else:
+ deleted.append(index)
+
+ for index in sorted(deleted, reverse=True):
+ self.store.remove(self.store.get_iter_from_string(str(index)))
+
+ if deleted:
+ self.select_first_item()
+ if failures:
+ name, error = failures[0]
+ self.show_error(
+ f"Deleted {len(deleted)} items. Could not delete {len(failures)} items.\n\n"
+ f"{name}: {error}"
+ )
+ else:
+ count = len(deleted)
+ self.set_status(f"Deleted {count} {'item' if count == 1 else 'items'}.")
+
+ def show_context_menu(self, event=None):
+ item = self.selected_item()
+ if item is None:
+ self.set_status("Select an item to open its context menu.")
+ return True
+
+ menu = Gtk.Menu()
+ open_item = Gtk.MenuItem.new_with_mnemonic("_Open")
+ open_item.connect("activate", lambda _menu_item: self.open_selected())
+ menu.append(open_item)
+
+ if any(selected.path is not None for _index, _path, selected in self.selected_items()):
+ delete_item = Gtk.MenuItem.new_with_mnemonic("_Delete")
+ delete_item.connect("activate", lambda _menu_item: self.delete_selected())
+ menu.append(delete_item)
+
+ menu.attach_to_widget(self.view, None)
+ menu.show_all()
+ self.suppress_focus_close = True
+ menu.connect("deactivate", lambda _menu: GLib.idle_add(self.allow_focus_close))
+
+ if event is not None:
+ menu.popup_at_pointer(event)
+ else:
+ menu.popup_at_widget(
+ self.view,
+ Gdk.Gravity.CENTER,
+ Gdk.Gravity.CENTER,
+ None,
+ )
+ return True
+
+ def allow_focus_close(self):
+ self.suppress_focus_close = False
+ return False
+
+ def on_focus_out(self, _widget, _event):
+ if self.suppress_focus_close:
+ return False
+ self.close()
+ return False
+
+ def on_search_changed(self, _entry):
+ self.filter_text = self.search_entry.get_text().strip().casefold()
+ self.filter_model.refilter()
+ self.select_first_item()
+
+ visible_count = 0
+ tree_iter = self.filter_model.get_iter_first()
+ while tree_iter is not None:
+ visible_count += 1
+ tree_iter = self.filter_model.iter_next(tree_iter)
+
+ noun = "item" if visible_count == 1 else "items"
+ self.set_status(f"{visible_count} {noun} found.")
+
+ def clear_search_or_close(self):
+ if self.search_entry.get_text():
+ self.search_entry.set_text("")
+ self.view.grab_focus()
+ else:
+ self.close()
+
+ def on_search_key_press(self, _entry, event):
+ if event.keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down):
+ self.select_first_item()
+ self.view.grab_focus()
+ return True
+ if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ self.open_selected()
+ return True
+ if event.keyval == Gdk.KEY_Escape:
+ self.clear_search_or_close()
+ return True
+ return False
+
+ def on_tree_key_press(self, _view, event):
+ if event.keyval in (Gdk.KEY_space, Gdk.KEY_KP_Space) and event.state & Gdk.ModifierType.CONTROL_MASK:
+ self.view.get_selection().unselect_all()
+ self.set_status("Selection cleared.")
+ return True
+ if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ self.open_selected()
+ return True
+ if event.keyval == Gdk.KEY_F10 and event.state & Gdk.ModifierType.SHIFT_MASK:
+ return self.show_context_menu()
+ if event.keyval in (Gdk.KEY_Delete, Gdk.KEY_KP_Delete):
+ self.delete_selected()
+ return True
+ if event.keyval == Gdk.KEY_Escape:
+ self.clear_search_or_close()
+ return True
+ characterCode = Gdk.keyval_to_unicode(event.keyval)
+ if (characterCode >= 32 and not event.state &
+ (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK |
+ Gdk.ModifierType.SUPER_MASK | Gdk.ModifierType.META_MASK)):
+ character = chr(characterCode)
+ self.search_entry.grab_focus()
+ self.search_entry.insert_text(character, -1)
+ self.search_entry.set_position(-1)
+ return True
+ return False
+
+ def on_tree_button_press(self, view, event):
+ if event.type != Gdk.EventType.BUTTON_PRESS or event.button != Gdk.BUTTON_SECONDARY:
+ return False
+
+ path_info = view.get_path_at_pos(int(event.x), int(event.y))
+ if path_info is not None:
+ path, column = path_info[0], path_info[1]
+ selection = view.get_selection()
+ selected_paths = selection.get_selected_rows()[1] if selection.path_is_selected(path) else []
+ view.set_cursor(path, column, False)
+ for selected_path in selected_paths:
+ selection.select_path(selected_path)
+ view.grab_focus()
+ return self.show_context_menu(event)
+
+ def on_row_activated(self, _view, _path, _column):
+ self.open_selected()
+
+ def on_open_clicked(self, _button):
+ self.open_selected()
+
+ def on_delete_clicked(self, _button):
+ self.delete_selected()
+
+
+def main():
+ GLib.set_application_name(APP_NAME)
+ items = read_shortcuts() + read_desktop_items()
+ DesktopWindow(items)
+ Gtk.main()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/desktop.sh b/scripts/desktop.sh
deleted file mode 100755
index c4d70a7..0000000
--- a/scripts/desktop.sh
+++ /dev/null
@@ -1,127 +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 .
-
-trim_whitespace() {
- local text="$1"
-
- text="${text#"${text%%[![:space:]]*}"}"
- text="${text%"${text##*[![:space:]]}"}"
- printf '%s' "$text"
-}
-
-get_shortcuts_file() {
- local configHome="${XDG_CONFIG_HOME:-$HOME/.config}"
- local candidatePath="${configHome}/i3/desktop_shortcuts"
-
- if [[ -r "$candidatePath" ]]; then
- printf '%s\n' "$candidatePath"
- return 0
- fi
-
- return 1
-}
-
-build_launch_command() {
- local shortcutCommand="$1"
- local shortcutWorkspace="$2"
- local workspaceEscaped=
-
- if [[ -z "$shortcutWorkspace" ]]; then
- printf '%s\n' "$shortcutCommand"
- return 0
- fi
-
- if ! command -v i3-msg &> /dev/null; then
- printf '%s\n' "$shortcutCommand"
- return 0
- fi
-
- if [[ "$shortcutWorkspace" =~ ^[0-9]+$ ]]; then
- printf 'i3-msg workspace number %s && %s\n' "$shortcutWorkspace" "$shortcutCommand"
- return 0
- fi
-
- workspaceEscaped=$(printf '%q' "$shortcutWorkspace")
- printf 'i3-msg workspace %s && %s\n' "$workspaceEscaped" "$shortcutCommand"
-}
-
-desktopPath="${HOME}/Desktop"
-if [[ ! -d "$desktopPath" ]]; then
- exit 0
-fi
-
-declare -a shortcutMenuList
-shortcutsFile="$(get_shortcuts_file)"
-if [[ -n "$shortcutsFile" ]]; then
- # Optional quick-launch format:
- # Pretty Name|command with args|workspace
- # Firefox|/usr/bin/firefox|9
- # Brave|/usr/bin/brave|
- while IFS= read -r rawLine || [[ -n "$rawLine" ]]; do
- line="$(trim_whitespace "$rawLine")"
- if [[ -z "$line" ]] || [[ "$line" =~ ^# ]]; then
- continue
- fi
-
- IFS='|' read -r shortcutName shortcutCommand shortcutWorkspace _ <<< "$line"
- shortcutName="$(trim_whitespace "$shortcutName")"
- shortcutCommand="$(trim_whitespace "$shortcutCommand")"
- shortcutWorkspace="$(trim_whitespace "$shortcutWorkspace")"
-
- if [[ -z "$shortcutName" ]] || [[ -z "$shortcutCommand" ]]; then
- continue
- fi
-
- shortcutMenuList+=("$shortcutName" "$(build_launch_command "$shortcutCommand" "$shortcutWorkspace")")
- done < "$shortcutsFile"
-fi
-
-# Discover directories and files on the desktop.
-mapfile -t desktopDirs < <(find "$desktopPath" -mindepth 1 -maxdepth 1 -type d)
-mapfile -t desktopFiles < <(find "$desktopPath" -mindepth 1 -maxdepth 1 -type f)
-desktopItems=("${desktopDirs[@]}" "${desktopFiles[@]}")
-
-# Build menu for yad.
-declare -a desktopMenuList
-for desktopItem in "${desktopItems[@]}"; do
- case "$desktopItem" in
- *.desktop)
- desktopName="$(grep -m 1 "^Name=" "$desktopItem" | cut -f2- -d '=')"
- if [[ -z "$desktopName" ]]; then
- desktopName="${desktopItem##*/}"
- fi
- desktopMenuList+=("$desktopName" "gio launch \"$desktopItem\"")
- ;;
- *)
- desktopMenuList+=("${desktopItem##*/}" "/usr/bin/env xdg-open \"$desktopItem\"")
- ;;
- esac
-done
-
-menuList=("${shortcutMenuList[@]}" "${desktopMenuList[@]}")
-if [[ "${#menuList[@]}" -eq 0 ]]; then
- exit 0
-fi
-
-if launch=$(yad --list \
- --title="I38 Desktop" \
- --column="Items" \
- --column="Launch Command" \
- --close-on-unfocus \
- --hide-column=2 \
- --search-column=1 \
- --skip-taskbar \
- "${menuList[@]}"); then
- launch="${launch%|}"
- launch="${launch##*|}"
- eval "$launch"
-fi