Files

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()