From da7ae925f2e69942998073a0af6f19628d2c1ba4 Mon Sep 17 00:00:00 2001 From: JustAnyone Date: Mon, 10 Aug 2026 17:44:25 +0300 Subject: [PATCH] refactor: stop blocking UI when collecting files --- random_file_picker/tui.py | 113 ++++++++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/random_file_picker/tui.py b/random_file_picker/tui.py index 7364a1d..f303853 100644 --- a/random_file_picker/tui.py +++ b/random_file_picker/tui.py @@ -9,12 +9,14 @@ Here's an example usage: import pathlib import random -from typing import override +from typing import cast, override import typed_argparse as tap +from textual import work from textual.app import App, ComposeResult from textual.binding import BindingType from textual.widgets import Footer, Header, Label +from textual.worker import Worker, WorkerState from random_file_picker.core import IMG_EXTENSIONS, collect_files, move_file_to_move_folder, trash_file, upload_file from random_file_picker.openers import ( @@ -69,6 +71,7 @@ class MenuApp(App[None]): self.usable_files: list[pathlib.Path] = [] self.current_index: int = 0 self.current_file: pathlib.Path | None = None + self.is_loading: bool = False @override def compose(self) -> ComposeResult: @@ -78,10 +81,77 @@ class MenuApp(App[None]): yield Footer() def on_mount(self) -> None: - self.gather_files() - if self.usable_files: - self.current_index = 0 - self.display_current_file() + self.start_gathering_files() + + @override + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Disable every action except quit while files are being gathered.""" + if self.is_loading and action != "quit": + return None + return True + + def start_gathering_files(self) -> None: + """Start file collection in a background worker so the UI stays responsive.""" + if not self.directory.exists() or not self.directory.is_dir(): + self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found") + return + + self.is_loading = True + self.refresh_bindings() + + self.query_one("#current-file", Label).update(f"🔍 Scanning '{self.directory}' for files, please wait...") + status = self.query_one("#status", Label) + status.update("") + status.loading = True + _ = self.gather_files_worker( + self.directory, self.file_limit, self.blacklist_patterns, self.whitelist_patterns, + recurse=self.recurse, + ) + + @work(thread=True, exclusive=True) + def gather_files_worker( + self, + directory: pathlib.Path, + file_limit: int, + blacklist_patterns: list[str], + whitelist_patterns: list[str], + *, + recurse: bool, + ) -> list[pathlib.Path]: + """Run file collection in a worker thread so a large/recursive scan doesn't block the UI.""" + return collect_files( + search_path=directory, + search_subdirectories=recurse, + file_size_limit=file_limit, + blacklist_patterns=blacklist_patterns, + whitelist_patterns=whitelist_patterns, + ) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """Handle the completion of the background file scan, updating the UI with results or errors.""" + worker = cast("Worker[list[pathlib.Path]]", event.worker) + if worker.name != "gather_files_worker": + return + + status = self.query_one("#status", Label) + self.is_loading = False + status.loading = False + self.refresh_bindings() + + if event.state == WorkerState.SUCCESS: + files = worker.result or [] + self.usable_files = list(files) + random.shuffle(self.usable_files) + + if not self.usable_files: + status.update("❌ No usable files found") + else: + status.update(f"✅ Found {len(self.usable_files)} files") + self.current_index = 0 + self.display_current_file() + + elif event.state == WorkerState.ERROR: + status.update(f"❌ Failed to scan directory: {worker.error}") def assert_current_file_exists(self) -> bool: """Check if the current file exists and update the status label accordingly.""" @@ -102,28 +172,6 @@ class MenuApp(App[None]): self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}") self.open_file() - def gather_files(self) -> None: - """Collect files from the directory based on filters.""" - self.usable_files = [] - - if not self.directory.exists() or not self.directory.is_dir(): - self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found") - return - - self.usable_files = collect_files( - search_path=self.directory, - search_subdirectories=self.recurse, - file_size_limit=self.file_limit, - blacklist_patterns=self.blacklist_patterns, - whitelist_patterns=self.whitelist_patterns, - ) - - if not self.usable_files: - self.query_one("#status", Label).update("❌ No usable files found") - else: - self.query_one("#status", Label).update(f"✅ Found {len(self.usable_files)} files") - random.shuffle(self.usable_files) - def open_file(self) -> None: """Open the current file - display images in terminal, play others externally.""" if not self.current_file: @@ -159,7 +207,7 @@ class MenuApp(App[None]): self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}") def delete_file(self) -> None: - """Delete the current file.""" + """Delete the current file (move to trash if enabled).""" if not self.current_file or self.current_index >= len(self.usable_files): return @@ -210,7 +258,7 @@ class MenuApp(App[None]): self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}") def open_in_folder(self) -> None: - """Open the file's folder in file manager.""" + """Open the current file's folder in the file manager.""" if not self.current_file: return @@ -221,30 +269,37 @@ class MenuApp(App[None]): self.query_one("#status", Label).update("📂 Opened file in folder") def action_upload(self) -> None: + """Upload the current file to Discord webhook.""" self.send_file() def action_delete(self) -> None: + """Delete the current file (move to trash if enabled).""" self.delete_file() def action_move_to_folder(self) -> None: + """Move the current file to the move folder.""" self.move_to_move_folder() def action_open_folder(self) -> None: + """Open the current file's folder in the file manager.""" self.open_in_folder() def action_next_file(self) -> None: + """Display the next file.""" if not self.usable_files: return self.current_index = (self.current_index + 1) % len(self.usable_files) self.display_current_file() def action_prev_file(self) -> None: + """Display the previous file.""" if not self.usable_files: return self.current_index = (self.current_index - 1) % len(self.usable_files) self.display_current_file() def action_reopen_file(self) -> None: + """Reopen the current file.""" if not self.current_file: return self.open_file()