From d6dd1e465222f2bf6e03c6f873609d8e68d9314d Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sun, 12 Jan 2025 14:39:41 +0700 Subject: [PATCH] clan-app: Change tkinter file dialogue to gtk4 file dialogue --- pkgs/clan-app/clan_app/api/file.py | 96 ------------ pkgs/clan-app/clan_app/api/file_gtk.py | 200 +++++++++++++++++++++++++ pkgs/clan-app/clan_app/app.py | 2 +- pkgs/clan-app/default.nix | 16 +- pkgs/clan-app/shell.nix | 4 +- 5 files changed, 216 insertions(+), 102 deletions(-) delete mode 100644 pkgs/clan-app/clan_app/api/file.py create mode 100644 pkgs/clan-app/clan_app/api/file_gtk.py diff --git a/pkgs/clan-app/clan_app/api/file.py b/pkgs/clan-app/clan_app/api/file.py deleted file mode 100644 index 5dd2b1d04..000000000 --- a/pkgs/clan-app/clan_app/api/file.py +++ /dev/null @@ -1,96 +0,0 @@ -# ruff: noqa: N801 - -import logging -from tkinter import Tk, filedialog - -from clan_cli.api import ApiError, ErrorDataClass, SuccessDataClass -from clan_cli.api.directory import FileFilter, FileRequest - -log = logging.getLogger(__name__) - - -def _apply_filters(filters: FileFilter | None) -> list[tuple[str, str]]: - if not filters: - return [] - - filter_patterns = [] - - if filters.mime_types: - # Tkinter does not directly support MIME types, so this section can be adjusted - # if you wish to handle them differently - filter_patterns.extend(filters.mime_types) - - if filters.patterns: - filter_patterns.extend(filters.patterns) - - if filters.suffixes: - suffix_patterns = [f"*.{suffix}" for suffix in filters.suffixes] - filter_patterns.extend(suffix_patterns) - - filter_title = filters.title if filters.title else "Custom Files" - - return [(filter_title, " ".join(filter_patterns))] - - -def open_file( - file_request: FileRequest, *, op_key: str -) -> SuccessDataClass[list[str] | None] | ErrorDataClass: - try: - root = Tk() - root.withdraw() # Hide the main window - root.attributes("-topmost", True) # Bring the dialogs to the front - - file_path: str = "" - multiple_files: list[str] = [] - - if file_request.mode == "open_file": - file_path = filedialog.askopenfilename( - title=file_request.title, - initialdir=file_request.initial_folder, - initialfile=file_request.initial_file, - filetypes=_apply_filters(file_request.filters), - ) - - elif file_request.mode == "select_folder": - file_path = filedialog.askdirectory( - title=file_request.title, initialdir=file_request.initial_folder - ) - - elif file_request.mode == "save": - file_path = filedialog.asksaveasfilename( - title=file_request.title, - initialdir=file_request.initial_folder, - initialfile=file_request.initial_file, - filetypes=_apply_filters(file_request.filters), - ) - - elif file_request.mode == "open_multiple_files": - tresult = filedialog.askopenfilenames( - title=file_request.title, - initialdir=file_request.initial_folder, - filetypes=_apply_filters(file_request.filters), - ) - multiple_files = list(tresult) - - if len(file_path) == 0 and len(multiple_files) == 0: - msg = "No file selected" - raise ValueError(msg) # noqa: TRY301 - - multiple_files = [file_path] if len(multiple_files) == 0 else multiple_files - return SuccessDataClass(op_key, status="success", data=multiple_files) - - except Exception as e: - log.exception("Error opening file") - return ErrorDataClass( - op_key=op_key, - status="error", - errors=[ - ApiError( - message=e.__class__.__name__, - description=str(e), - location=["open_file"], - ) - ], - ) - finally: - root.destroy() diff --git a/pkgs/clan-app/clan_app/api/file_gtk.py b/pkgs/clan-app/clan_app/api/file_gtk.py new file mode 100644 index 000000000..fd63f3b9c --- /dev/null +++ b/pkgs/clan-app/clan_app/api/file_gtk.py @@ -0,0 +1,200 @@ +# ruff: noqa: N801 +import gi + +gi.require_version("Gtk", "4.0") + +import logging +import time +from pathlib import Path +from typing import Any + +from clan_cli.api import ApiError, ErrorDataClass, SuccessDataClass +from clan_cli.api.directory import FileRequest +from gi.repository import Gio, GLib, Gtk + +log = logging.getLogger(__name__) + + +def remove_none(_list: list) -> list: + return [i for i in _list if i is not None] + + +RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {} + + +def open_file( + file_request: FileRequest, *, op_key: str +) -> SuccessDataClass[list[str] | None] | ErrorDataClass: + GLib.idle_add(gtk_open_file, file_request, op_key) + + while RESULT.get(op_key) is None: + time.sleep(0.2) + response = RESULT[op_key] + del RESULT[op_key] + return response + + +def gtk_open_file(file_request: FileRequest, op_key: str) -> bool: + def returns(data: SuccessDataClass | ErrorDataClass) -> None: + global RESULT + RESULT[op_key] = data + + def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: + try: + gfile = file_dialog.open_finish(task) + if gfile: + selected_path = remove_none([gfile.get_path()]) + returns( + SuccessDataClass( + op_key=op_key, data=selected_path, status="success" + ) + ) + except Exception as e: + log.exception("Error opening file") + returns( + ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message=e.__class__.__name__, + description=str(e), + location=["open_file"], + ) + ], + ) + ) + + def on_file_select_multiple(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: + try: + gfiles: Any = file_dialog.open_multiple_finish(task) + if gfiles: + selected_paths = remove_none([gfile.get_path() for gfile in gfiles]) + returns( + SuccessDataClass( + op_key=op_key, data=selected_paths, status="success" + ) + ) + else: + returns(SuccessDataClass(op_key=op_key, data=None, status="success")) + except Exception as e: + log.exception("Error opening file") + returns( + ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message=e.__class__.__name__, + description=str(e), + location=["open_file"], + ) + ], + ) + ) + + def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: + try: + gfile = file_dialog.select_folder_finish(task) + if gfile: + selected_path = remove_none([gfile.get_path()]) + returns( + SuccessDataClass( + op_key=op_key, data=selected_path, status="success" + ) + ) + else: + returns(SuccessDataClass(op_key=op_key, data=None, status="success")) + except Exception as e: + log.exception("Error opening file") + returns( + ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message=e.__class__.__name__, + description=str(e), + location=["open_file"], + ) + ], + ) + ) + + def on_save_finish(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: + try: + gfile = file_dialog.save_finish(task) + if gfile: + selected_path = remove_none([gfile.get_path()]) + returns( + SuccessDataClass( + op_key=op_key, data=selected_path, status="success" + ) + ) + else: + returns(SuccessDataClass(op_key=op_key, data=None, status="success")) + except Exception as e: + log.exception("Error opening file") + returns( + ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message=e.__class__.__name__, + description=str(e), + location=["open_file"], + ) + ], + ) + ) + + dialog = Gtk.FileDialog() + + if file_request.title: + dialog.set_title(file_request.title) + + if file_request.filters: + filters = Gio.ListStore.new(Gtk.FileFilter) + file_filters = Gtk.FileFilter() + + if file_request.filters.title: + file_filters.set_name(file_request.filters.title) + + if file_request.filters.mime_types: + for mime in file_request.filters.mime_types: + file_filters.add_mime_type(mime) + filters.append(file_filters) + + if file_request.filters.patterns: + for pattern in file_request.filters.patterns: + file_filters.add_pattern(pattern) + + if file_request.filters.suffixes: + for suffix in file_request.filters.suffixes: + file_filters.add_suffix(suffix) + + filters.append(file_filters) + dialog.set_filters(filters) + + if file_request.initial_file: + p = Path(file_request.initial_file).expanduser() + f = Gio.File.new_for_path(str(p)) + dialog.set_initial_file(f) + + if file_request.initial_folder: + p = Path(file_request.initial_folder).expanduser() + f = Gio.File.new_for_path(str(p)) + dialog.set_initial_folder(f) + + # if select_folder + if file_request.mode == "select_folder": + dialog.select_folder(callback=on_folder_select) + if file_request.mode == "open_multiple_files": + dialog.open_multiple(callback=on_file_select_multiple) + elif file_request.mode == "open_file": + dialog.open(callback=on_file_select) + elif file_request.mode == "save": + dialog.save(callback=on_save_finish) + + return GLib.SOURCE_REMOVE diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index 4071c9e8e..e78b45caf 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -12,7 +12,7 @@ from pathlib import Path from clan_cli.api import API from clan_cli.custom_logger import setup_logging -from clan_app.api.file import open_file +from clan_app.api.file_gtk import open_file from clan_app.deps.webview.webview import Size, SizeHint, Webview diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index 4c5ca67ac..0112f6681 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -7,6 +7,9 @@ webview-lib, fontconfig, pythonRuntime, + wrapGAppsHook4, + gobject-introspection, + gtk4, }: let source = ./.; @@ -20,13 +23,14 @@ let mimeTypes = [ "x-scheme-handler/clan" ]; }; - # Runtime binary dependencies required by the application runtimeDependencies = [ - + gobject-introspection + gtk4 ]; pyDeps = ps: [ - ps.tkinter + ps.pygobject3 + ps.pygobject-stubs ]; # Dependencies required for running tests @@ -70,7 +74,10 @@ pythonRuntime.pkgs.buildPythonApplication { (pythonRuntime.withPackages (ps: [ ps.setuptools ])) copyDesktopItems fontconfig - ]; + + # gtk4 deps + wrapGAppsHook4 + ] ++ runtimeDependencies; # The necessity of setting buildInputs and propagatedBuildInputs to the # same values for your Python package within Nix largely stems from ensuring @@ -120,6 +127,7 @@ pythonRuntime.pkgs.buildPythonApplication { # Additional pass-through attributes passthru.desktop-file = desktop-file; passthru.devshellPyDeps = ps: (pyTestDeps ps) ++ (pyDeps ps); + passthru.runtimeDeps = runtimeDependencies; passthru.pythonRuntime = pythonRuntime; postInstall = '' diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 43253f255..b490fd49e 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -12,6 +12,8 @@ mkShell { inputsFrom = [ self'.devShells.default ]; + inherit (clan-app) nativeBuildInputs propagatedBuildInputs; + buildInputs = [ (clan-app.pythonRuntime.withPackages ( ps: @@ -22,7 +24,7 @@ mkShell { ] ++ (clan-app.devshellPyDeps ps) )) - ]; + ] ++ clan-app.runtimeDeps; shellHook = '' export GIT_ROOT=$(git rev-parse --show-toplevel)