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