Source code for emlib.dialogs

"""
Simple dialogs for use at the repl / ipython / jupyter

At the moment the better supported backend is qt5, which works
in all three major platforms.
"""
from __future__ import annotations
import os
import sys
from emlib.common import runonce
from functools import cache
import logging


from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from typing import Sequence

_DEFAULT_FONT = ("Helvetica", 11)

logger = logging.getLogger(__name__)

__all__ = (
    'showInfo',
    'selectFile',
    'saveDialog',
    'selectItem',
    'selectItems',
    'filters'
)


filters = {
    'All': 'All (*.*)',
    'Sound': 'Sound (*.wav, *.aiff, *.flac, *.mp3)',
    'Image': 'Image (*.jpg, *.png)'
}


@runonce
def _has_qt5() -> bool:
    try:
        from PyQt5 import QtWidgets
        return True
    except ImportError:
        return False


@runonce
def _has_qt6() -> bool:
    try:
        from PyQt6 import QtWidgets
        return True
    except ImportError:
        return False


@runonce
def _has_tk() -> bool:
    # tk in linux with python 3.13 is often broken
    if sys.platform == 'linux' and sys.version_info.minor >= 13:
        return False
    try:
        import tkinter
        return True
    except ImportError:
        return False


@cache
def _resolveBackend(backend='') -> str:
    if not backend:
        if _has_qt6():
            backend = 'qt6'
        elif _has_qt5():
            backend = 'qt5'
        elif _has_tk():
            backend = 'tk'
        else:
            raise RuntimeError("No backends available. Install pyqt6 via 'pip install pyqt6'")

    if backend == 'qt6' and not _has_qt6():
        raise RuntimeError("pyqt6 is needed, but is not installed. Install it via 'pip install pyqt6'")

    if backend == 'qt5' and not _has_qt5():
        raise RuntimeError("pyqt5 is needed, but is not installed. Install it via 'pip install pyqt5'")
    return backend


[docs] def showInfo(msg: str, title: str = "Info", font=None, icon='', backend='' ) -> None: """ Show a pop up dialog with some info Args: msg: the text to display (one line) title: the title of the dialog font: if given, a tuple (fontfamily, size) icon: either None or one of 'question', 'information', 'warning', 'critical' """ backend = _resolveBackend(backend) if backend == 'qt5': from . import _dialogsqt return _dialogsqt.showInfo(msg=msg, title=title, font=font, icon=icon) elif backend == 'qt6': from . import _dialogsqt6 return _dialogsqt6.showInfo(msg=msg, title=title, font=font, icon=icon) from tkinter import ttk, Tk try: from ttkthemes import ThemedTk root = ThemedTk(theme="breeze") except ImportError: root = Tk() root.title(title) bg = "#f5f5f5" frame = ttk.Frame(root) dx, dy = 8, 8 if not font: font = (_DEFAULT_FONT[0], int(_DEFAULT_FONT[1]*1.3)) ttk.Label(frame, text="ℹ " + msg, font=font, background=bg ).grid(column=0, row=0, padx=dx*2, pady=dy*2) ttk.Button(frame, text="Ok", command=root.destroy ).grid(column=0, row=1, padx=dx, pady=dy) frame.grid(column=0, row=0) root.bind("<Escape>", lambda *args: root.destroy()) root.mainloop()
[docs] def selectFile(directory='', filter="All (*.*)", title="Open file", backend='' ) -> str: """ Create a dialog to open a file and returns the file selected Args: filter: a string of the form "<Mask> (<glob>)". Multiple filters can be used, for example: ``"Image (*.png, *.jpg);; Video (*.mp4, *.mov)"`` title: the title of the dialog directory: the initial directory backend: one of qt, tk, or None to select a default Returns: the selected filename, or an empty string if the dialog is dismissed """ backend = _resolveBackend(backend) if backend == 'qt5': from . import _dialogsqt return _dialogsqt.selectFile(directory=directory, filter=filter, title=title) elif backend == 'qt6': from . import _dialogsqt6 return _dialogsqt6.selectFile(directory=directory, filter=filter, title=title) if not _has_tk(): raise ValueError("tk is not available") from tkinter import ttk, Tk try: from ttkthemes import ThemedTk root = ThemedTk(theme="breeze") except ImportError: root = Tk() from tkinter import filedialog filetypes = _tkParseFilter(filter) if not filetypes: filetypes = [('All', '(*.*)')] root.withdraw() path = filedialog.askopenfilename(initialdir=directory, title=title, filetypes=filetypes) root.destroy() return path
def _tkParseFilter(filter: str) -> list[tuple[str, str]]: # A filter has the form <name1> (<wildcard1>, <wildcard2>, ...);; name2... parts = filter.split(";;") out = [] for part in parts: part = part.strip() if "(" in part and part[-1] == ")": # <name> (wildcards) name, wildcardstr = part[:-1].split("(") wildcardstr.replace(',', ' ') wildcards = wildcardstr.split() out.append((name.strip(), ' '.join(wildcards))) else: # <wildcard> out.append(('', part)) return out def _saveDialogTk(filter="All (*.*)", title="Save file", directory="~") -> str: from tkinter import ttk, Tk try: from ttkthemes import ThemedTk root = ThemedTk(theme="breeze") except ImportError: root = Tk() from tkinter import filedialog filetypes = _tkParseFilter(filter) root.withdraw() path = filedialog.asksaveasfilename(initialdir=directory, title=title, filetypes=filetypes) root.destroy() return path
[docs] def saveDialog(filter="All (*.*)", title="Save file", directory="~", backend='' ) -> str: """ Open a dialog to save a file. .. note:: At the moment macos only supports the 'qt' backend Args: filter: a string of the form "<Mask> (<glob>)". Multiple filters can be used, for example: ``"Image (*.png, *.jpg);; Video (*.mp4, *.mov)"`` title: the title of the dialog directory: the initial directory backend: one of 'qt', 'tkinter' or None to select the backend based on available packages Returns: the save filename, or an empty string if the dialog is dismissed """ backend = _resolveBackend(backend) if not directory: directory = "~" directory = os.path.expanduser(directory) if backend == 'qt5': from . import _dialogsqt return _dialogsqt.saveDialog(filter=filter, title=title, directory=directory) if backend == 'qt6': from . import _dialogsqt6 return _dialogsqt6.saveDialog(filter=filter, title=title, directory=directory) else: if sys.platform == 'darwin': raise RuntimeError("tk backend not supported in macos") return _saveDialogTk(filter=filter, title=title, directory=directory)
[docs] def selectItem(items: Sequence[str], title="Select", entryFont=('Arial', 15), listFont=('Arial', 12), scrollbar=True, width=400, numlines=20, caseSensitive=False, ensureSelection=False, backend='' ) -> str | None: """ Select one item from a list Args: items: the list of options title: the title of the dialog entryFont: the font of the filter text entry (a tuple (font, size)) listFont: the font of the list (a tuple (font, size)) scrollbar: if True, add a scrollbar width: the width in pixels numlines: the number of lines to display at a time caseSensitive: if True, filtering is case sensitive ensureSelection: if True, raises a ValueError exception is no selection was done Returns: either the selected item or None """ selected = selectItems(items=items, title=title, entryFont=entryFont, listFont=listFont, scrollbar=scrollbar, width=width, numlines=numlines, caseSensitive=caseSensitive, ensureSelection=ensureSelection, backend=backend) return selected[0] if selected else None
[docs] def selectItems(items: Sequence[str], title="Select", entryFont=('Arial', 14), listFont=('Arial', 12), scrollbar=True, width=400, numlines=20, caseSensitive=False, ensureSelection=False, backend='' ) -> list[str]: """ Select one or multiple items from a list Args: items: the list of options title: the title of the dialog entryFont: the font of the filter text entry (a tuple (font, size)) listFont: the font of the list (a tuple (font, size)) scrollbar: if True, add a scrollbar width: the width in pixels numlines: the number of lines to display at a time caseSensitive: if True, filtering is case sensitive ensureSelection: if True, raises a ValueError exception is no selection was done backend: if given, one of 'qt6', 'qt5', 'tk' Returns: a list of selected items, or an empty list if the user aborted (via Escape or closing the window) """ backend = _resolveBackend(backend) if backend == 'tk': return _selectFromListTk(items=items, title=title, entryFont=entryFont, listFont=listFont, scrollbar=scrollbar, width=width, numlines=numlines, caseSensitive=caseSensitive, ensureSelection=ensureSelection) elif backend == 'qt6': if not _has_qt6(): raise RuntimeError("qt6 not installed. Install it via 'pip install pyqt6'") logger.info("Multiple item selection is not supported in qt at the moment") from . import _dialogsqt6 out = _dialogsqt6.selectItem(items=items, title=title, listFont=listFont, entryFont=entryFont) return [out] if out is not None else [] elif backend == 'qt5': if not _has_qt5(): raise RuntimeError("qt5 not installed. Install it via 'pip install pyqt5'") logger.info("Multiple item selection is not supported in qt at the moment") from . import _dialogsqt out = _dialogsqt.selectItem(items=items, title=title, listFont=listFont, entryFont=entryFont) return [out] if out is not None else [] else: raise ValueError("Backends supported: 'qt5', 'qt6', 'tk'")
def _selectFromListTk(items: Sequence[str], title="Select", entryFont=('Arial', 15), listFont=('Arial', 12), scrollbar=True, width=400, numlines=20, caseSensitive=False, ensureSelection=False, correctionFactor=1.0 ) -> list[str]: """ Select one or multiple items from a list Args: items: the list of options title: the title of the dialog entryFont: the font of the filter text entry (a tuple (font, size)) listFont: the font of the list (a tuple (font, size)) scrollbar: if True, add a scrollbar width: the width in pixels numlines: the number of lines to display at a time caseSensitive: if True, filtering is case sensitive ensureSelection: if True, raises a ValueError exception is no selection was done Returns: a list of selected items, or an empty list if the user aborted (via Escape or closing the window) """ if sys.platform == 'darwin': logger.error("macOS is not supported") from tkinter import ttk, Tk root = Tk() #try: # from ttkthemes import ThemedTk # root = ThemedTk(theme="breeze") #except ImportError: # root = Tk() import tkinter as tk from tkinter.font import Font if len(items) < numlines: scrollbar = False numlines = min(numlines, len(items)) root.title(title) root.columnconfigure(0, weight=1) longest = max((item for item in items), key=len) tkfont = Font(root=root, font=listFont) minwidth = int(tkfont.measure(longest) * correctionFactor) width = max(width, minwidth) filterval = tk.StringVar() entry = ttk.Entry(root, textvariable=filterval, font=entryFont) entry.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") treestyle = ttk.Style() treestyle.configure("selectFromList.Treeview", highlightthickness=0, bd=0, font=listFont) treestyle.layout("selectFromList.Treeview", [ ('selectFromList.Treeview.treearea', {'sticky': 'nswe'})]) tree = ttk.Treeview(root, height=numlines, show='tree', style='selectFromList.Treeview' ) tree.grid(row=1, column=0, sticky='nsew') tree.column("#0", minwidth=0, width=width, stretch=False) # adding data to the treeview itemids = [tree.insert('', tk.END, text=item, open=False) for item in items] id2item = {i: c for i, c in zip(itemids, items)} # add a scrollbar if scrollbar: scrollbarWidget = ttk.Scrollbar(root, orient=tk.VERTICAL, command=tree.yview) scrollbarWidget.grid(row=1, column=1, sticky='ns') tree.configure(yscroll=scrollbarWidget.set) else: tree.configure(yscroll=None) id2visible = {i: True for i in itemids} def applyfilter(text, caseSensitive): idx = 0 if not caseSensitive: text = text.lower() for c, i in zip(items, itemids): if not caseSensitive: c = c.lower() if text in c: if not id2visible[i]: tree.reattach(i, '', idx) id2visible[i] = True idx += 1 elif id2visible[i]: tree.detach(i) id2visible[i] = False ch = tree.get_children() if ch: tree.focus(ch[0]) tree.selection_set(ch[0]) out = [None] def accept(*args): sels = tree.selection() values = [id2item[sel] for sel in sels] out[0] = values root.destroy() def entrymove(step=1): tree.focus_set() sel = tree.selection() if sel: item = tree.next(sel[0]) if step == 1 else tree.prev(sel[0]) if not item: item = sel[0] else: children = tree.get_children() if not children: return item = children[0 if step == 1 else -1] tree.selection_set(item) tree.focus(item) def entrykey(k): s = filterval.get()+k.char filterval.set(s) entry.icursor(len(s)) entry.focus_set() def entryback(): s = filterval.get()[:-1] filterval.set(s) entry.icursor(len(s)) entry.focus_set() root.bind("<Escape>", lambda *args: root.destroy()) root.bind("<Return>", accept) entry.bind("<KeyRelease>", lambda *args: applyfilter(filterval.get(), caseSensitive=caseSensitive)) entry.bind("<Down>", lambda *args: entrymove(1)) entry.bind("<Up>", lambda *args: entrymove(-1)) for k in "abcdefghijklmnopqrstuvwxyzABCEFGHIJKLMNOPQRSTUVWXYZ0123456789": tree.bind(k, entrykey) tree.bind("<BackSpace>", lambda *args: entryback()) tree.bind('<Double-Button-1>', accept) tree.focus(itemids[0]) tree.selection_set(itemids[0]) entry.focus_set() root.mainloop() sel = out[0] if not sel and ensureSelection: raise ValueError("No selection was done") return sel if sel else []