375 lines
17 KiB
Python
375 lines
17 KiB
Python
#!/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()
|