1#!/usr/local/bin/python3.8 2# 3# Misc common helper classes and functions for the Hatari UI 4# 5# Copyright (C) 2008-2019 by Eero Tamminen 6# 7# This program is free software; you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation; either version 2 of the License, or 10# (at your option) any later version. 11# 12# This program is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16 17import os 18import sys 19import gi 20# use correct version of gtk 21gi.require_version('Gtk', '3.0') 22from gi.repository import Gtk 23from gi.repository import GObject 24 25 26# leak debugging 27#import gc 28#gc.set_debug(gc.DEBUG_UNCOLLECTABLE) 29 30 31# --------------------- 32# Hatari UI information 33 34class UInfo: 35 """singleton constants for the UI windows, 36 one instance is needed to initialize these properly""" 37 version = "v1.4" 38 name = "Hatari UI" 39 logo = "hatari-logo.png" 40 # TODO: use share/icons/hicolor/*/apps/hatari.png instead 41 icon = "hatari-icon.png" 42 copyright = "UI copyright (C) 2008-2019 by Eero Tamminen" 43 44 # path to the directory where the called script resides 45 path = os.path.dirname(sys.argv[0]) 46 47 def __init__(self, path = None): 48 "UIinfo([path]), set suitable paths for resources from CWD and path" 49 if path: 50 self.path = path 51 if not os.path.exists(UInfo.icon): 52 UInfo.icon = self._get_path(UInfo.icon) 53 if not os.path.exists(UInfo.logo): 54 UInfo.logo = self._get_path(UInfo.logo) 55 56 def _get_path(self, filename): 57 sep = os.path.sep 58 testpath = "%s%s%s" % (self.path, sep, filename) 59 if os.path.exists(testpath): 60 return testpath 61 62 63# -------------------------------------------------------- 64# functions for showing HTML files 65 66class UIHelp: 67 def __init__(self): 68 """determine HTML viewer and where docs are""" 69 self._view = self.get_html_viewer() 70 self._path = self.get_doc_path() 71 72 def get_html_viewer(self): 73 """return name of html viewer or None""" 74 path = self.get_binary_path("xdg-open") 75 if path: 76 return path 77 path = self.get_binary_path("firefox") 78 if path: 79 return path 80 return None 81 82 def get_binary_path(self, name): 83 """return true if given binary is in path""" 84 # could also try running the binary with "--version" arg 85 # and check the exec return value 86 if os.sys.platform == "win32": 87 splitter = ';' 88 else: 89 splitter = ':' 90 for i in os.environ['PATH'].split(splitter): 91 fname = os.path.join(i, name) 92 if os.access(fname, os.X_OK) and not os.path.isdir(fname): 93 return fname 94 return None 95 96 def get_doc_path(self): 97 """return path or URL to Hatari docs or None""" 98 # first try whether there are local Hatari docs in standard place 99 # for this Hatari/UI version 100 sep = os.sep 101 path = self.get_binary_path("hatari") 102 path = sep.join(path.split(sep)[:-2]) # remove "bin/hatari" 103 path = path + sep + "share" + sep + "doc" + sep + "hatari" + sep 104 if os.path.exists(path + "manual.html"): 105 return path 106 # if not, point to latest Hatari HG version docs 107 print("WARNING: Hatari manual not found at:", path + "manual.html") 108 return "http://hg.tuxfamily.org/mercurialroot/hatari/hatari/raw-file/tip/doc/" 109 110 def set_mainwin(self, widget): 111 self.mainwin = widget 112 113 def view_url(self, url, name): 114 """view given URL or file path, or error use 'name' as its name""" 115 if self._view and "://" in url or os.path.exists(url): 116 print("RUN: '%s' '%s'" % (self._view, url)) 117 os.spawnlp(os.P_NOWAIT, self._view, self._view, url) 118 return 119 if not self._view: 120 msg = "Cannot view %s, HTML viewer missing" % name 121 else: 122 msg = "Cannot view %s,\n'%s' file is missing" % (name, url) 123 from dialogs import ErrorDialog 124 ErrorDialog(self.mainwin).run(msg) 125 126 def view_hatari_manual(self, dummy=None): 127 self.view_url(self._path + "manual.html", "Hatari manual") 128 129 def view_hatari_compatibility(self, dummy=None): 130 self.view_url(self._path + "compatibility.html", "Hatari compatibility list") 131 132 def view_hatari_releasenotes(self, dummy=None): 133 self.view_url(self._path + "release-notes.txt", "Hatari release notes") 134 135 def view_hatari_todo(self, dummy=None): 136 self.view_url(self._path + "todo.txt", "Hatari TODO items") 137 138 def view_hatari_mails(self, dummy=None): 139 self.view_url("http://hatari.tuxfamily.org/contact.html", "Hatari mailing lists") 140 141 def view_hatari_repository(self, dummy=None): 142 self.view_url("http://hg.tuxfamily.org/mercurialroot/hatari/hatari", "latest Hatari changes") 143 144 def view_hatari_authors(self, dummy=None): 145 self.view_url(self._path + "authors.txt", "Hatari authors") 146 147 def view_hatari_page(self, dummy=None): 148 self.view_url("http://hatari.tuxfamily.org/", "Hatari home page") 149 150 def view_hatariui_page(self, dummy=None): 151 self.view_url("http://eerott.mbnet.fi/hatari/hatari-ui.shtml", "Hatari UI home page") 152 153 154# -------------------------------------------------------- 155# auxiliary class+callback to be used with the PasteDialog 156 157class HatariTextInsert: 158 def __init__(self, hatari, text): 159 self.index = 0 160 self.text = text 161 self.pressed = False 162 self.hatari = hatari 163 print("OUTPUT '%s'" % text) 164 GObject.timeout_add(100, _text_insert_cb, self) 165 166# callback to insert text object to Hatari character at the time 167# (first key down, on next call up), at given interval 168def _text_insert_cb(textobj): 169 char = textobj.text[textobj.index] 170 if char == ' ': 171 # white space gets stripped, use scancode instead 172 char = "57" 173 if textobj.pressed: 174 textobj.pressed = False 175 textobj.hatari.insert_event("keyup %s" % char) 176 textobj.index += 1 177 if textobj.index >= len(textobj.text): 178 del(textobj) 179 return False 180 else: 181 textobj.pressed = True 182 textobj.hatari.insert_event("keydown %s" % char) 183 # call again 184 return True 185 186 187# ---------------------------- 188# helper functions for buttons 189 190def create_button(label, cb, data = None): 191 "create_button(label,cb[,data]) -> button widget" 192 button = Gtk.Button(label) 193 if data == None: 194 button.connect("clicked", cb) 195 else: 196 button.connect("clicked", cb, data) 197 return button 198 199def create_toolbutton(stock_id, cb, data = None): 200 "create_toolbutton(stock_id,cb[,data]) -> toolbar button with stock icon+label" 201 button = Gtk.ToolButton(stock_id) 202 if data == None: 203 button.connect("clicked", cb) 204 else: 205 button.connect("clicked", cb, data) 206 return button 207 208def create_toggle(label, cb, data = None): 209 "create_toggle(label,cb[,data]) -> toggle button widget" 210 button = Gtk.ToggleButton(label) 211 if data == None: 212 button.connect("toggled", cb) 213 else: 214 button.connect("toggled", cb, data) 215 return button 216 217 218# ----------------------------- 219# Table dialog helper functions 220# 221# TODO: rewrite to use Gtk.Grid instead of Gtk.Table 222 223def create_table_dialog(parent, title, rows, cols, oktext = Gtk.STOCK_APPLY): 224 "create_table_dialog(parent,title,rows, cols, oktext) -> (table,dialog)" 225 dialog = Gtk.Dialog(title, parent, 226 Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, 227 (oktext, Gtk.ResponseType.APPLY, 228 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)) 229 230 table = Gtk.Table(rows, cols) 231 table.set_col_spacings(8) 232 dialog.vbox.add(table) 233 return (table, dialog) 234 235def table_add_entry_row(table, row, col, label, size = None): 236 "table_add_entry_row(table,row,col,label,[entry size]) -> entry" 237 # add given label to given row in given table 238 # return entry for that line 239 label = Gtk.Label(label=label, halign=Gtk.Align.END) 240 table.attach(label, col, col+1, row, row+1, Gtk.AttachOptions.FILL) 241 col += 1 242 if size: 243 entry = Gtk.Entry(max_length=size, width_chars=size, halign=Gtk.Align.START) 244 table.attach(entry, col, col+1, row, row+1) 245 else: 246 entry = Gtk.Entry() 247 table.attach(entry, col, col+1, row, row+1) 248 return entry 249 250def table_add_widget_row(table, row, col, label, widget, fullspan = False): 251 "table_add_widget_row(table,row,col,label,widget) -> widget" 252 # add given label right aligned to given row in given table 253 # add given widget to the right column and returns it 254 # return entry for that line 255 if label: 256 if fullspan: 257 lcol = 0 258 else: 259 lcol = col 260 label = Gtk.Label(label=label, halign=Gtk.Align.END) 261 table.attach(label, lcol, lcol+1, row, row+1, Gtk.AttachOptions.FILL) 262 if fullspan: 263 table.attach(widget, 1, col+2, row, row+1) 264 else: 265 table.attach(widget, col+1, col+2, row, row+1) 266 return widget 267 268def table_add_radio_rows(table, row, col, label, texts, cb = None): 269 "table_add_radio_rows(table,row,col,label,texts[,cb]) -> [radios]" 270 # - add given label right aligned to given row in given table 271 # - create/add radio buttons with given texts to next row, set 272 # the one given as "active" as active and set 'cb' as their 273 # "toggled" callback handler 274 # - return array or radiobuttons 275 label = Gtk.Label(label=label, halign=Gtk.Align.END) 276 table.attach(label, col, col+1, row, row+1) 277 278 radios = [] 279 radio = None 280 box = Gtk.VBox() 281 for text in texts: 282 radio = Gtk.RadioButton(group=radio, label=text) 283 if cb: 284 radio.connect("toggled", cb, text) 285 radios.append(radio) 286 box.add(radio) 287 table.attach(box, col+1, col+2, row, row+1) 288 return radios 289 290def table_add_separator(table, row): 291 "table_add_separator(table,row)" 292 widget = Gtk.HSeparator() 293 endcol = table.get_property("n-columns") 294 # separator for whole table width 295 table.attach(widget, 0, endcol, row, row+1, Gtk.AttachOptions.FILL) 296 297 298# ----------------------------- 299# File selection helpers 300 301def get_open_filename(title, parent, path = None): 302 buttons = (Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) 303 fsel = Gtk.FileChooserDialog(title, parent, Gtk.FileChooserAction.OPEN, buttons) 304 fsel.set_local_only(True) 305 if path: 306 fsel.set_filename(path) 307 if fsel.run() == Gtk.ResponseType.OK: 308 filename = fsel.get_filename() 309 else: 310 filename = None 311 fsel.destroy() 312 return filename 313 314def get_save_filename(title, parent, path = None): 315 buttons = (Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) 316 fsel = Gtk.FileChooserDialog(title, parent, Gtk.FileChooserAction.SAVE, buttons) 317 fsel.set_local_only(True) 318 fsel.set_do_overwrite_confirmation(True) 319 if path: 320 fsel.set_filename(path) 321 if not os.path.exists(path): 322 # above set only folder, this is needed to set 323 # the file name when the file doesn't exist 324 fsel.set_current_name(os.path.basename(path)) 325 if fsel.run() == Gtk.ResponseType.OK: 326 filename = fsel.get_filename() 327 else: 328 filename = None 329 fsel.destroy() 330 return filename 331 332 333# File selection button with eject button 334class FselAndEjectFactory: 335 def __init__(self): 336 pass 337 338 def get(self, label, path, filename, action): 339 "returns file selection button and box having that + eject button" 340 fsel = Gtk.FileChooserButton(label) 341 # Hatari cannot access URIs 342 fsel.set_local_only(True) 343 fsel.set_width_chars(12) 344 fsel.set_action(action) 345 if filename: 346 fsel.set_filename(filename) 347 elif path: 348 fsel.set_current_folder(path) 349 eject = create_button("Eject", self._eject, fsel) 350 351 box = Gtk.HBox() 352 box.pack_start(fsel, True, True, 0) 353 box.pack_start(eject, False, False, 0) 354 return (fsel, box) 355 356 def _eject(self, widget, fsel): 357 fsel.unselect_all() 358 359 360# Gtk is braindead, there's no way to set a default filename 361# for file chooser button unless it already exists 362# - set_filename() works only for files that already exist 363# - set_current_name() works only for SAVE action, 364# but file chooser button doesn't support that 365# i.e. I had to do my own (less nice) container widget... 366class FselEntry: 367 def __init__(self, parent, validate = None, data = None): 368 self._parent = parent 369 self._validate = validate 370 self._validate_data = data 371 entry = Gtk.Entry() 372 entry.set_width_chars(12) 373 entry.set_editable(False) 374 hbox = Gtk.HBox() 375 hbox.add(entry) 376 button = create_button("Select...", self._select_file_cb) 377 hbox.pack_start(button, False, False, 0) 378 self._entry = entry 379 self._hbox = hbox 380 381 def _select_file_cb(self, widget): 382 fname = self._entry.get_text() 383 while True: 384 fname = get_save_filename("Select file", self._parent, fname) 385 if not fname: 386 # assume cancel 387 return 388 if self._validate: 389 # filename needs validation and is valid? 390 if not self._validate(self._validate_data, fname): 391 continue 392 self._entry.set_text(fname) 393 return 394 395 def set_filename(self, fname): 396 self._entry.set_text(fname) 397 398 def get_filename(self): 399 return self._entry.get_text() 400 401 def get_container(self): 402 return self._hbox 403