541 lines
19 KiB
Python
Executable File
541 lines
19 KiB
Python
Executable File
#!/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 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
|
|
|
|
from shortcuts import desktop_directory
|
|
|
|
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():
|
|
try:
|
|
desktop_path = desktop_directory()
|
|
except ValueError:
|
|
return []
|
|
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()
|
|
return name.startswith(self.filter_text)
|
|
|
|
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()
|