clan-app: Change tkinter file dialogue to gtk4 file dialogue

This commit is contained in:
Qubasa
2025-01-12 14:39:41 +07:00
parent 6db757637d
commit 68f56ecafd
5 changed files with 216 additions and 102 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -12,7 +12,7 @@ from pathlib import Path
from clan_cli.api import API from clan_cli.api import API
from clan_cli.custom_logger import setup_logging 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 from clan_app.deps.webview.webview import Size, SizeHint, Webview

View File

@@ -7,6 +7,9 @@
webview-lib, webview-lib,
fontconfig, fontconfig,
pythonRuntime, pythonRuntime,
wrapGAppsHook4,
gobject-introspection,
gtk4,
}: }:
let let
source = ./.; source = ./.;
@@ -20,13 +23,14 @@ let
mimeTypes = [ "x-scheme-handler/clan" ]; mimeTypes = [ "x-scheme-handler/clan" ];
}; };
# Runtime binary dependencies required by the application
runtimeDependencies = [ runtimeDependencies = [
gobject-introspection
gtk4
]; ];
pyDeps = ps: [ pyDeps = ps: [
ps.tkinter ps.pygobject3
ps.pygobject-stubs
]; ];
# Dependencies required for running tests # Dependencies required for running tests
@@ -70,7 +74,10 @@ pythonRuntime.pkgs.buildPythonApplication {
(pythonRuntime.withPackages (ps: [ ps.setuptools ])) (pythonRuntime.withPackages (ps: [ ps.setuptools ]))
copyDesktopItems copyDesktopItems
fontconfig fontconfig
];
# gtk4 deps
wrapGAppsHook4
] ++ runtimeDependencies;
# The necessity of setting buildInputs and propagatedBuildInputs to the # The necessity of setting buildInputs and propagatedBuildInputs to the
# same values for your Python package within Nix largely stems from ensuring # same values for your Python package within Nix largely stems from ensuring
@@ -120,6 +127,7 @@ pythonRuntime.pkgs.buildPythonApplication {
# Additional pass-through attributes # Additional pass-through attributes
passthru.desktop-file = desktop-file; passthru.desktop-file = desktop-file;
passthru.devshellPyDeps = ps: (pyTestDeps ps) ++ (pyDeps ps); passthru.devshellPyDeps = ps: (pyTestDeps ps) ++ (pyDeps ps);
passthru.runtimeDeps = runtimeDependencies;
passthru.pythonRuntime = pythonRuntime; passthru.pythonRuntime = pythonRuntime;
postInstall = '' postInstall = ''

View File

@@ -12,6 +12,8 @@ mkShell {
inputsFrom = [ self'.devShells.default ]; inputsFrom = [ self'.devShells.default ];
inherit (clan-app) nativeBuildInputs propagatedBuildInputs;
buildInputs = [ buildInputs = [
(clan-app.pythonRuntime.withPackages ( (clan-app.pythonRuntime.withPackages (
ps: ps:
@@ -22,7 +24,7 @@ mkShell {
] ]
++ (clan-app.devshellPyDeps ps) ++ (clan-app.devshellPyDeps ps)
)) ))
]; ] ++ clan-app.runtimeDeps;
shellHook = '' shellHook = ''
export GIT_ROOT=$(git rev-parse --show-toplevel) export GIT_ROOT=$(git rev-parse --show-toplevel)