# Misc common helper classes and functions for the Hatari UI # # Copyright (C) 2008-2025 by Eero Tamminen # # SPDX-License-Identifier: GPL-2.0-or-later import os import sys import gi # use correct version of gtk gi.require_version('Gtk', '3.0') from gi.repository import Gtk from gi.repository import GLib # leak debugging #import gc #gc.set_debug(gc.DEBUG_UNCOLLECTABLE) # --------------------- # Hatari UI information class UInfo: "constants for the UI windows" version = "v1.4" name = "Hatari UI" copyright = "Python/Gtk UI copyright (C) 2008-2025 by Eero Tamminen" def __init__(self, path = None): "UIinfo([path]), set suitable paths for resources from CWD and path" if path: self.path = path else: # path to the directory where the called script resides self.path = os.path.dirname(sys.argv[0]) # TODO: use share/icons/hicolor/*/apps/hatari.png instead self.icon = "hatari-icon.png" self.logo = "hatari-logo.png" if not os.path.exists(self.icon): self.icon = self._get_path(self.icon) if not os.path.exists(self.logo): self.logo = self._get_path(self.logo) def _get_path(self, filename): sep = os.path.sep testpath = "%s%s%s" % (self.path, sep, filename) if os.path.exists(testpath): return testpath return "" # -------------------------------------------------------- # functions for showing HTML files class UIHelp: def __init__(self): """determine HTML viewer and where docs are""" self._view = self.get_html_viewer() self._path, self._uipath = self.get_doc_path() def get_html_viewer(self): """return name of html viewer or None""" path = self.get_binary_path("xdg-open") if path: return path path = self.get_binary_path("firefox") if path: return path return None def get_binary_path(self, name): """return true if given binary is in path""" # could also try running the binary with "--version" arg # and check the exec return value if os.sys.platform == "win32": splitter = ';' else: splitter = ':' for i in os.environ['PATH'].split(splitter): fname = os.path.join(i, name) if os.access(fname, os.X_OK) and not os.path.isdir(fname): return fname return None def get_doc_path(self): """return path or URL to Hatari docs or None""" # first try whether there are local Hatari docs in standard place # for this Hatari/UI version sep = os.sep path = self.get_binary_path("hatari") path = sep.join(path.split(sep)[:-2]) # remove "bin/hatari" docpath = path + "/share/doc/hatari/" if not os.path.exists(docpath + "manual.html"): print("WARNING: using Hatari website URLs, Hatari 'manual.html' not found in: %s" % docpath) docpath = "https://www.hatari-emu.org/doc/" uipath = path + "/share/doc/hatari/hatariui/" if not os.path.exists(uipath + "README"): print("WARNING: Using Hatari UI Git URLs, Hatari UI 'README' not found in: %s" % uipath) uipath = "https://framagit.org/hatari/hatari/-/raw/main/python-ui/" return docpath, uipath def set_mainwin(self, widget): self.mainwin = widget def view_url(self, url, name): """view given URL or file path, or error use 'name' as its name""" if self._view and "://" in url or os.path.exists(url): print("RUN: '%s' '%s'" % (self._view, url)) os.spawnlp(os.P_NOWAIT, self._view, self._view, url) return if not self._view: msg = "Cannot view %s, HTML viewer missing" % name else: msg = "Cannot view %s,\n'%s' file is missing" % (name, url) from dialogs import ErrorDialog ErrorDialog(self.mainwin).run(msg) def view_hatari_manual(self, dummy=None): self.view_url(self._path + "manual.html", "Hatari manual") def view_hatari_compatibility(self, dummy=None): self.view_url(self._path + "compatibility.html", "Hatari compatibility list") def view_hatari_releasenotes(self, dummy=None): self.view_url(self._path + "release-notes.txt", "Hatari release notes") def view_hatariui_page(self, dummy=None): self.view_url(self._path + "hatari-ui.html", "Hatari UI information") def view_hatariui_releasenotes(self, dummy=None): self.view_url(self._uipath + "release-notes.txt", "Hatari UI release notes") def view_hatari_bugs(self, dummy=None): self.view_url(self._path + "bugs.txt", "Hatari bugs") def view_hatari_todo(self, dummy=None): self.view_url(self._path + "todo.txt", "Hatari TODO items") def view_hatari_authors(self, dummy=None): self.view_url(self._path + "authors.txt", "Hatari authors") def view_hatari_mails(self, dummy=None): self.view_url("https://www.hatari-emu.org/contact.html", "Hatari mailing lists") def view_hatari_repository(self, dummy=None): self.view_url("https://framagit.org/hatari/hatari/-/commits/main", "latest Hatari changes") def view_hatari_page(self, dummy=None): self.view_url("https://www.hatari-emu.org/", "Hatari home page") # -------------------------------------------------------- # auxiliary class+callback to be used with the PasteDialog class HatariTextInsert: def __init__(self, hatari, text): self.index = 0 self.text = text self.pressed = False self.hatari = hatari print("OUTPUT '%s'" % text) GLib.timeout_add(100, _text_insert_cb, self) # callback to insert text object to Hatari character at the time # (first key down, on next call up), at given interval def _text_insert_cb(textobj): char = textobj.text[textobj.index] if char == ' ': # white space gets stripped, use scancode instead char = "57" if textobj.pressed: textobj.pressed = False textobj.hatari.insert_event("keyup %s" % char) textobj.index += 1 if textobj.index >= len(textobj.text): del(textobj) return False else: textobj.pressed = True textobj.hatari.insert_event("keydown %s" % char) # call again return True # ---------------------------- # helper functions for buttons def create_button(label, cb, data = None): "create_button(label,cb[,data]) -> button widget" button = Gtk.Button(label) if data == None: button.connect("clicked", cb) else: button.connect("clicked", cb, data) return button def create_toolbutton(stock_id, cb, data = None): "create_toolbutton(stock_id,cb[,data]) -> toolbar button with stock icon+label" button = Gtk.ToolButton(stock_id) if data == None: button.connect("clicked", cb) else: button.connect("clicked", cb, data) return button def create_toggle(label, cb, data = None): "create_toggle(label,cb[,data]) -> toggle button widget" button = Gtk.ToggleButton(label) if data == None: button.connect("toggled", cb) else: button.connect("toggled", cb, data) return button # ----------------------------- # Table dialog helper functions # # TODO: rewrite to use Gtk.Grid instead of Gtk.Table def create_table_dialog(parent, title, rows, cols, oktext = Gtk.STOCK_APPLY): "create_table_dialog(parent,title,rows, cols, oktext) -> (table,dialog)" dialog = Gtk.Dialog(title, parent, Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, (oktext, Gtk.ResponseType.APPLY, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)) table = Gtk.Table(rows, cols) table.set_col_spacings(8) dialog.vbox.add(table) return (table, dialog) def table_add_entry_row(table, row, col, label, size = None): "table_add_entry_row(table,row,col,label[,entry size]) -> entry" # add given label to given row in given table # return entry for that line label = Gtk.Label(label=label, halign=Gtk.Align.END) table.attach(label, col, col+1, row, row+1, Gtk.AttachOptions.FILL) col += 1 if size: entry = Gtk.Entry(max_length=size, width_chars=size, halign=Gtk.Align.START) table.attach(entry, col, col+1, row, row+1) else: entry = Gtk.Entry() table.attach(entry, col, col+1, row, row+1) return entry def table_add_widget_row(table, row, col, label, widget, fullspan = False): "table_add_widget_row(table,row,col,label,widget[,fullspan]) -> widget" # add given label right aligned to given row in given table # add given widget to the right column and return it if label: if fullspan: lcol = 0 else: lcol = col label = Gtk.Label(label=label, halign=Gtk.Align.END) table.attach(label, lcol, lcol+1, row, row+1, Gtk.AttachOptions.FILL) if fullspan: table.attach(widget, 1, col+2, row, row+1) else: table.attach(widget, col+1, col+2, row, row+1) return widget def table_add_combo_row(table, row, col, label, texts, cb = None, data = None): "table_add_combo_row(table,row,col,label,texts[,cb]) -> combo" # - add given label right aligned to given row in given table # - create/add combo box with given texts to right column and return it combo = Gtk.ComboBoxText() for text in texts: combo.append_text(text) if cb: combo.connect("changed", cb, data) return table_add_widget_row(table, row, col, label, combo, True) def table_add_radio_rows(table, row, col, label, texts, cb = None): "table_add_radio_rows(table,row,col,label,texts[,cb]) -> [radios]" # - add given label right aligned to given row in given table # - create/add radio buttons with given texts to next row, set # the one given as "active" as active and set 'cb' as their # "toggled" callback handler # - return array or radiobuttons label = Gtk.Label(label=label, halign=Gtk.Align.END) table.attach(label, col, col+1, row, row+1) radios = [] radio = None box = Gtk.VBox() for text in texts: radio = Gtk.RadioButton(group=radio, label=text) if cb: radio.connect("toggled", cb, text) radios.append(radio) box.add(radio) table.attach(box, col+1, col+2, row, row+1) return radios def table_add_separator(table, row): "table_add_separator(table,row)" widget = Gtk.HSeparator() endcol = table.get_property("n-columns") # separator for whole table width table.attach(widget, 0, endcol, row, row+1, Gtk.AttachOptions.FILL) # ----------------------------- # File selection helpers def get_open_filename(title, parent, path = None): buttons = (Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) fsel = Gtk.FileChooserDialog(title, parent, Gtk.FileChooserAction.OPEN, buttons) fsel.set_local_only(True) if path: fsel.set_filename(path) if fsel.run() == Gtk.ResponseType.OK: filename = fsel.get_filename() else: filename = None fsel.destroy() return filename def get_save_filename(title, parent, path = None): buttons = (Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) fsel = Gtk.FileChooserDialog(title, parent, Gtk.FileChooserAction.SAVE, buttons) fsel.set_local_only(True) fsel.set_do_overwrite_confirmation(True) if path: fsel.set_filename(path) if not os.path.exists(path): # above set only folder, this is needed to set # the file name when the file doesn't exist fsel.set_current_name(os.path.basename(path)) if fsel.run() == Gtk.ResponseType.OK: filename = fsel.get_filename() else: filename = None fsel.destroy() return filename # File selection button with eject button class FselAndEjectFactory: def __init__(self): pass def get(self, label, path, filename, action): "returns file selection button and box having that + eject button" fsel = Gtk.FileChooserButton(title=label) # Hatari cannot access URIs fsel.set_local_only(True) fsel.set_width_chars(12) fsel.set_action(action) if filename: fsel.set_filename(filename) elif path: fsel.set_current_folder(path) eject = create_button("Eject", self._eject, fsel) box = Gtk.HBox() box.pack_start(fsel, True, True, 0) box.pack_start(eject, False, False, 0) return (fsel, box) def _eject(self, widget, fsel): fsel.unselect_all() # Gtk is braindead, there's no way to set a default filename # for file chooser button unless it already exists # - set_filename() works only for files that already exist # - set_current_name() works only for SAVE action, # but file chooser button doesn't support that # i.e. I had to do my own (less nice) container widget... class FselEntry: def __init__(self, parent, title = "Select a file", validate = None, data = None): self._title = title self._parent = parent self._validate = validate self._validate_data = data entry = Gtk.Entry() entry.set_width_chars(12) entry.set_editable(False) hbox = Gtk.HBox() hbox.add(entry) button = create_button("Select...", self._select_file_cb) hbox.pack_start(button, False, False, 0) self._entry = entry self._hbox = hbox def _select_file_cb(self, widget): fname = self._entry.get_text() while True: fname = get_save_filename(self._title, self._parent, fname) if not fname: # assume cancel return if self._validate: # filename needs validation and is valid? if not self._validate(self._validate_data, fname): continue self._entry.set_text(fname) return def set_filename(self, fname): self._entry.set_text(fname) def get_filename(self): return self._entry.get_text() def get_container(self): return self._hbox