#!/usr/bin/env python3 # This file is part of I38, licensed under the GNU GPL, version 3 or later. import argparse import threading 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, GLib, Gtk from shortcuts import (clipboard_target, create_shortcut, detect_wine_prefix, fetch_page_title, installed_applications, parse_target, validate_wine_prefix) class ApplicationDialog(Gtk.Dialog): def __init__(self, parent): super().__init__(title="Search for application", transient_for=parent, modal=True) self.set_default_size(620, 420) self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "S_elect", Gtk.ResponseType.OK) self.set_default_response(Gtk.ResponseType.OK) box = self.get_content_area() box.set_spacing(8) box.set_border_width(12) label = Gtk.Label.new_with_mnemonic("_Search applications:") label.set_xalign(0) self.searchEntry = Gtk.SearchEntry() label.set_mnemonic_widget(self.searchEntry) self.searchEntry.get_accessible().set_name("Search applications") box.pack_start(label, False, False, 0) box.pack_start(self.searchEntry, False, False, 0) self.store = Gtk.ListStore(str, str, str) for app in installed_applications(): self.store.append((app.get_display_name(), app.get_id(), app.get_filename())) self.query = "" self.filtered = self.store.filter_new() self.filtered.set_visible_func(lambda model, row, _data: model[row][0].casefold().startswith(self.query)) self.view = Gtk.TreeView(model=self.filtered) self.view.set_enable_search(False) self.view.get_accessible().set_name("Applications") for index, title in enumerate(("Application", "Launcher")): self.view.append_column(Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=index)) self.view.connect("row-activated", self.confirm_selection) self.view.connect("key-press-event", self.on_list_key) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(self.view) box.pack_start(scroll, True, True, 0) self.countLabel = Gtk.Label(xalign=0) box.pack_start(self.countLabel, False, False, 0) self.searchEntry.connect("changed", self.on_search) self.searchEntry.connect("activate", self.confirm_selection) self.searchEntry.connect("key-press-event", self.on_search_key) self.view.get_selection().connect("changed", self.on_selection) self.on_search(self.searchEntry) self.show_all() self.searchEntry.grab_focus() def on_search(self, entry): self.query = entry.get_text().casefold() self.filtered.refilter() if len(self.filtered): self.view.set_cursor(Gtk.TreePath.new_first()) self.countLabel.set_text("{} applications found.".format(len(self.filtered))) self.on_selection(self.view.get_selection()) def on_selection(self, selection): _model, row = selection.get_selected() self.set_response_sensitive(Gtk.ResponseType.OK, row is not None) def on_search_key(self, _entry, event): if event.keyval == Gdk.KEY_Down: self.view.grab_focus() return True return False def on_list_key(self, _view, event): character = Gdk.keyval_to_unicode(event.keyval) if character >= 32 and not event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK | Gdk.ModifierType.SUPER_MASK): self.searchEntry.grab_focus() self.searchEntry.insert_text(chr(character), -1) self.searchEntry.set_position(-1) return True if event.keyval == Gdk.KEY_BackSpace: self.searchEntry.set_text(self.searchEntry.get_text()[:-1]) self.searchEntry.grab_focus() self.searchEntry.set_position(-1) return True return False def selected_path(self): model, row = self.view.get_selection().get_selected() return model[row][2] if row is not None else None def confirm_selection(self, *_args): if self.selected_path() is not None: self.response(Gtk.ResponseType.OK) class ShortcutWindow(Gtk.Window): def __init__(self, fileManager=""): super().__init__(title="I38 Create Desktop Shortcut") self.fileManager = fileManager self.target = None self.requestNumber = 0 self.nameVersion = 0 self.closed = False self.set_default_size(650, 300) self.set_border_width(12) self.connect("destroy", Gtk.main_quit) self.connect("destroy", self.on_destroy) self.connect("key-press-event", self.on_key) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.add(box) instructions = Gtk.Label(label="Choose a source, then review the target and shortcut name.", xalign=0) box.pack_start(instructions, False, False, 0) for label, callback in (("Add from _Clipboard", self.on_clipboard), ("Browse for _File", self.on_file), ("Browse for F_older", self.on_folder), ("Search for _Application", self.on_application)): button = Gtk.Button.new_with_mnemonic(label) button.connect("clicked", callback) box.pack_start(button, False, False, 0) self.targetEntry = Gtk.Entry() self.targetEntry.set_placeholder_text("/usr/bin/firefox, ~/Documents, or https://example.org/") self.add_field(box, "_Target (path, program, or web URL):", self.targetEntry) self.nameEntry = Gtk.Entry() self.add_field(box, "Shortcut _name:", self.nameEntry) self.wineBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.wineBox.set_no_show_all(True) self.wineEntry = Gtk.Entry() self.wineEntry.set_activates_default(True) self.add_field(self.wineBox, "_Wine prefix (bottle) folder:", self.wineEntry) wineButton = Gtk.Button.new_with_mnemonic("_Browse for Wine Prefix") wineButton.connect("clicked", self.on_wine_prefix) self.wineBox.pack_start(wineButton, False, False, 0) box.pack_start(self.wineBox, False, False, 0) self.targetEntry.connect("changed", self.on_target_changed) self.targetEntry.connect("activate", self.on_target_activate) self.nameEntry.connect("changed", self.on_name_changed) self.nameEntry.set_activates_default(True) self.terminalCheck = Gtk.CheckButton.new_with_mnemonic("Run program in a ter_minal") self.terminalCheck.set_sensitive(False) box.pack_start(self.terminalCheck, False, False, 0) self.statusLabel = Gtk.Label(label="No source selected.", xalign=0) self.statusLabel.set_line_wrap(True) self.statusLabel.set_selectable(True) self.statusLabel.get_accessible().set_name("Shortcut status") box.pack_start(self.statusLabel, False, False, 0) buttons = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL) buttons.set_layout(Gtk.ButtonBoxStyle.END) closeButton = Gtk.Button.new_with_mnemonic("C_lose") closeButton.connect("clicked", lambda *_args: self.close()) self.createButton = Gtk.Button.new_with_mnemonic("C_reate Shortcut") self.createButton.set_can_default(True) self.createButton.connect("clicked", self.on_create) self.createButton.set_sensitive(False) buttons.add(closeButton) buttons.add(self.createButton) box.pack_start(buttons, False, False, 0) self.set_default(self.createButton) self.show_all() box.get_children()[1].grab_focus() def add_field(self, box, text, entry): label = Gtk.Label.new_with_mnemonic(text) label.set_xalign(0) label.set_mnemonic_widget(entry) box.pack_start(label, False, False, 0) box.pack_start(entry, False, False, 0) def show_message(self, message, error=False): dialog = Gtk.MessageDialog(transient_for=self, modal=True, message_type=Gtk.MessageType.ERROR if error else Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.CLOSE, text="Could not create shortcut" if error else "Desktop shortcut created") dialog.format_secondary_text(message) dialog.run() dialog.destroy() def on_target_changed(self, _entry): self.requestNumber += 1 self.target = None self.createButton.set_sensitive(False) self.terminalCheck.set_active(False) self.terminalCheck.set_sensitive(False) self.wineBox.hide() self.wineEntry.set_text("") self.statusLabel.set_text("Press Enter to check the target, or choose a source above.") def on_name_changed(self, _entry): self.nameVersion += 1 self.createButton.set_sensitive(self.target is not None and bool(self.nameEntry.get_text().strip())) def set_target(self, target): self.targetEntry.set_text(target.value) self.target = target self.nameEntry.set_text(target.name) self.terminalCheck.set_sensitive(target.kind in ("Program", "Windows program")) self.statusLabel.set_text("{}: {}".format(target.kind, target.value)) self.nameEntry.grab_focus() self.nameEntry.select_region(0, -1) if target.kind == "Windows program": winePrefix = detect_wine_prefix(target.value) self.wineEntry.set_text(winePrefix or "") self.wineBox.set_no_show_all(False) self.wineBox.show_all() self.wineBox.set_no_show_all(True) if winePrefix: self.statusLabel.set_text("Windows program: {}. Wine prefix: {}".format(target.value, winePrefix)) else: self.statusLabel.set_text("Choose the Wine prefix (bottle) folder before creating this shortcut.") self.wineEntry.grab_focus() if target.kind == "Web link": requestNumber = self.requestNumber nameVersion = self.nameVersion self.statusLabel.set_text("Looking up page title. You can edit the name or create the shortcut now.") def lookup(): try: title = fetch_page_title(target.value) except Exception: # Network, TLS, decoding, and malformed HTML failures all # leave the usable hostname suggestion in place. title = None GLib.idle_add(self.apply_title, requestNumber, nameVersion, target, title) threading.Thread(target=lookup, daemon=True).start() def apply_title(self, requestNumber, nameVersion, target, title): if self.closed or requestNumber != self.requestNumber: return False if nameVersion == self.nameVersion and title: self.nameEntry.set_text(title) self.nameEntry.select_region(0, -1) self.statusLabel.set_text("Page title found." if title else "Page title unavailable. Using the site hostname unless you edit the name.") return False def on_destroy(self, _widget): self.closed = True self.requestNumber += 1 def on_target_activate(self, _entry): try: self.set_target(parse_target(self.targetEntry.get_text())) except (ValueError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) def on_clipboard(self, _button): self.requestNumber += 1 requestNumber = self.requestNumber clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.statusLabel.set_text("Reading clipboard…") def received_text(_clipboard, text, _data): if requestNumber != self.requestNumber: return try: self.set_target(clipboard_target(text or "", "text")) except (ValueError, UnicodeError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) def received_contents(_clipboard, selection, targetType): if requestNumber != self.requestNumber: return try: self.set_target(clipboard_target(selection.get_data() or b"", targetType)) except (ValueError, UnicodeError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) def received_targets(_clipboard, targets, _targetCount, _data): if requestNumber != self.requestNumber: return names = {atom.name(): atom for atom in targets or []} for targetType in ("x-special/gnome-copied-files", "text/uri-list", "application/x-kde4-urilist"): if targetType in names: clipboard.request_contents(names[targetType], received_contents, targetType) return clipboard.request_text(received_text, None) clipboard.request_targets(received_targets, None) def choose_path(self, folder): self.requestNumber += 1 dialog = Gtk.FileChooserDialog(title="Choose folder" if folder else "Choose file or program", transient_for=self, action=Gtk.FileChooserAction.SELECT_FOLDER if folder else Gtk.FileChooserAction.OPEN) dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Select", Gtk.ResponseType.OK) dialog.set_local_only(True) if dialog.run() == Gtk.ResponseType.OK: value = dialog.get_filename() else: value = None dialog.destroy() if value: try: self.set_target(parse_target(value)) except (ValueError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) def on_file(self, _button): self.choose_path(False) def on_folder(self, _button): self.choose_path(True) def on_wine_prefix(self, _button): dialog = Gtk.FileChooserDialog(title="Choose Wine prefix (bottle) folder", transient_for=self, modal=True, action=Gtk.FileChooserAction.SELECT_FOLDER) dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Select", Gtk.ResponseType.OK) dialog.set_local_only(True) dialog.set_show_hidden(True) if dialog.run() == Gtk.ResponseType.OK: self.wineEntry.set_text(dialog.get_filename()) dialog.destroy() self.wineEntry.grab_focus() def on_application(self, _button): self.requestNumber += 1 try: dialog = ApplicationDialog(self) value = dialog.selected_path() if dialog.run() == Gtk.ResponseType.OK else None dialog.destroy() if value: self.set_target(parse_target(value)) except (ValueError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) def on_create(self, _button): if self.target is None: return winePrefix = None if self.target.kind == "Windows program": try: winePrefix = validate_wine_prefix(self.wineEntry.get_text()) except ValueError as error: self.show_message(str(error), error=True) self.wineEntry.grab_focus() return try: destination = create_shortcut(self.target, self.nameEntry.get_text(), self.fileManager, self.terminalCheck.get_active(), winePrefix=winePrefix) except (ValueError, OSError, GLib.Error) as error: self.show_message(str(error), error=True) return self.show_message("Created {}.\n\nOpen I38 Desktop to use it.".format(destination)) self.close() def on_key(self, _widget, event): if event.keyval == Gdk.KEY_Escape: self.close() return True return False def main(): parser = argparse.ArgumentParser(description="Create an I38 desktop shortcut") parser.add_argument("--file-manager", default="", help="I38's selected file manager executable") args = parser.parse_args() GLib.set_application_name("I38 Create Desktop Shortcut") ShortcutWindow(args.file_manager) Gtk.main() if __name__ == "__main__": main()