From d75f16c25d6a6942c46edb9d30eaef9f4fc459b4 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Mon, 12 May 2025 14:29:04 -0600 Subject: [PATCH] clean up UI on drive viewer --- drive_view_tui.py | 91 ++++++++++++------- drive_view_tui.tcss | 52 +++++++++-- maildir_gtd/app.py | 28 ++++-- maildir_gtd/screens/DocumentViewer.py | 10 +-- utils/file_icons.py | 123 ++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 50 deletions(-) create mode 100644 utils/file_icons.py diff --git a/drive_view_tui.py b/drive_view_tui.py index d2b2369..ff0a794 100644 --- a/drive_view_tui.py +++ b/drive_view_tui.py @@ -30,6 +30,9 @@ from textual.worker import Worker, get_current_worker from textual import work from textual.widgets.option_list import Option +# Import file icons utility - note the updated import +from utils.file_icons import get_file_icon + # Import our DocumentViewerScreen sys.path.append(os.path.join(os.path.dirname(__file__), "maildir_gtd")) from maildir_gtd.screens.DocumentViewer import DocumentViewerScreen @@ -96,26 +99,24 @@ class OneDriveTUI(App): yield Header(show_clock=True) with Container(id="main_container"): - yield LoadingIndicator(id="loading") - yield Label("Authenticating with Microsoft Graph API...", id="status_label") + with Horizontal(id="top_bar"): + yield Button("\uf148 Up", id="back_button", classes="hide") + yield Label("Authenticating with Microsoft Graph API...", id="status_label") + yield LoadingIndicator(id="loading") + yield OptionList( + Option("Following", id="following"), + Option("Root", id="root"), + id="view_options" + ) with Container(id="auth_container"): yield Label("", id="auth_message") yield Button("Login", id="login_button", variant="primary") with Container(id="content_container", classes="hide"): - with Horizontal(): - with Vertical(id="navigation_container"): - - yield OptionList( - Option("Following", id="following"), - Option("Root", id="root"), - id="view_options" - ) - - with Vertical(id="items_container"): - yield DataTable(id="items_table") - yield Label("No items found", id="no_items_label", classes="hide") + with Vertical(id="items_container"): + yield DataTable(id="items_table") + yield Label("No items found", id="no_items_label", classes="hide") yield Footer() @@ -126,7 +127,7 @@ class OneDriveTUI(App): # Initialize the table table = self.query_one("#items_table") table.cursor_type = "row" - table.add_columns("Type", "Name", "Last Modified", "Size", "Web URL") + table.add_columns("◇", "Name", "Last Modified", "Size", "Web URL") # Load cached token if available if os.path.exists(self.cache_file): @@ -183,6 +184,8 @@ class OneDriveTUI(App): async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" + if event.button.id == "back_button": + self.action_navigate_back() if event.button.id == "login_button": self.initiate_device_flow() @@ -275,7 +278,7 @@ class OneDriveTUI(App): elif selected_option == "root": self.current_view = "Root" if self.selected_drive_id: - self.load_root_items() + self.load_drive_folder_items() async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: """Handle row selection in the items table.""" @@ -287,6 +290,7 @@ class OneDriveTUI(App): def open_item(self, selected_id: str): if selected_id: + self.folder_history.append(FolderHistoryEntry(self.current_folder_id, self.current_folder_name, self.selected_drive_id)) # Get an item from current items by ID string selected_row = self.current_items[selected_id] @@ -294,19 +298,19 @@ class OneDriveTUI(App): # Check if it's a folder is_folder = bool(selected_row.get("folder")) - if is_folder: - self.notify(f"Selected folder: {item_name}") + self.notify(f"Selected folder: {item_name}", timeout=1) # Load items in the folder + self.query_one("#back_button").remove_class("hide") self.query_one("#status_label").update(f"Loading items in folder: {item_name}") - self.load_root_items(folder_id=selected_id, drive_id=selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id)) + self.load_drive_folder_items(folder_id=selected_id, drive_id=selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id)) else: self.notify(f"Selected file: {item_name}") self.action_view_document() @work - async def load_root_items(self, folder_id: str = "", drive_id: str = "", track_history: bool = True): + async def load_drive_folder_items(self, folder_id: str = "", drive_id: str = "", track_history: bool = True): """Load root items from the selected drive.""" if not self.access_token or not self.selected_drive_id: return @@ -315,10 +319,10 @@ class OneDriveTUI(App): headers = {"Authorization": f"Bearer {self.access_token}"} url = f"https://graph.microsoft.com/v1.0/me/drive/root/children" - if folder_id and drive_id: + if folder_id and drive_id and folder_id != "root": url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children" - if track_history: - self.folder_history.append(FolderHistoryEntry(folder_id, self.current_folder_name, drive_id)) + # if track_history: + # self.folder_history.append(FolderHistoryEntry(folder_id, self.current_folder_name, drive_id)) self.selected_drive_id = drive_id self.current_folder_id = folder_id self.current_folder_name = self.current_items[folder_id].get("name", "Unknown") @@ -338,7 +342,13 @@ class OneDriveTUI(App): except Exception as e: self.notify(f"Error loading root items: {str(e)}", severity="error") - self.query_one("#status_label").update("Ready") + #update the status label with breadcrumbs from the folder_history + if self.folder_history: + breadcrumbs = " / \uf07b ".join([entry.folder_name for entry in self.folder_history]) + self.query_one("#status_label").update(f"\uf07b {breadcrumbs} / \uf07b {self.current_folder_name}") + else: + self.query_one("#status_label").update(f" \uf07b {self.current_folder_name}") + @work async def load_followed_items(self): @@ -384,9 +394,8 @@ class OneDriveTUI(App): name = item.get("name", "Unknown") is_folder = bool(item.get("folder")) - # Add folder icon if it's a folder and we're in root view - - item_type = "📁" if is_folder else "📄" + # Get icon for the file type + item_type = get_file_icon(name, is_folder) # Format the last modified date last_modified = item.get("lastModifiedDateTime", "") @@ -413,9 +422,21 @@ class OneDriveTUI(App): web_url = item.get("webUrl", "") item_id = item.get("id") - item_drive_id = item.get("parentReference", {}).get("driveId", self.selected_drive_id) - row_key = table.add_row(item_type, name, last_modified, size_str, web_url, key=item.get("id")) - # add item to to the list of current items keyed by row_key so we can look up all information later + + # Limit filename length to 160 characters + display_name = name[:50] + '...' if len(name) > 50 else name + + # Add row to table with the appropriate icon class for styling + row_key = table.add_row( + item_type, + display_name, + last_modified, + size_str, + web_url, + key=item.get("id") + ) + + # Add item to the list of current items keyed by row_key so we can look up all information later self.current_items[row_key] = item async def action_next_view(self) -> None: @@ -424,7 +445,7 @@ class OneDriveTUI(App): if self.current_view == "Following": option_list.highlighted = 1 # Switch to Root self.current_view = "Root" - self.load_root_items() + self.load_drive_folder_items() else: option_list.highlighted = 0 # Switch to Following self.current_view = "Following" @@ -438,7 +459,7 @@ class OneDriveTUI(App): if self.current_view == "Following": self.load_followed_items() elif self.current_view == "Root": - self.load_root_items() + self.load_drive_folder_items() self.notify("Refreshed items") async def action_toggle_follow(self) -> None: @@ -479,13 +500,15 @@ class OneDriveTUI(App): """Quit the application.""" self.exit() - async def action_navigate_back(self) -> None: + def action_navigate_back(self) -> None: """Navigate back to the previous folder.""" if self.folder_history: previous_entry = self.folder_history.pop() self.current_folder_id = previous_entry.folder_id self.current_folder_name = previous_entry.folder_name - self.load_root_items(folder_id=previous_entry.folder_id, drive_id=previous_entry.parent_id, track_history=False) + if len(self.folder_history) <= 0: + self.query_one("#back_button").add_class("hide") + self.load_drive_folder_items(folder_id=previous_entry.folder_id, drive_id=previous_entry.parent_id, track_history=False) else: self.notify("No previous folder to navigate back to") diff --git a/drive_view_tui.tcss b/drive_view_tui.tcss index af90a5e..fe2b2d1 100644 --- a/drive_view_tui.tcss +++ b/drive_view_tui.tcss @@ -42,18 +42,26 @@ } /* Status and loading elements */ -#status_label { + +#top_bar { + height: 4; + + #status_label { text-align: center; color: $accent; padding:1; + width: 2fr + } + + #view_options { + border: round $secondary; + width: 1fr; + min-width: 40; + } + } - -#view_options { - border: round $secondary; -} - #loading_container { height: 3; width: 100%; @@ -96,11 +104,42 @@ height: 100%; } + + #items_table { width: 100%; height: auto; } +/* File icon styling in the data table */ +.datatable--cell:first-child { + color: $accent; + text-align: center; + padding-right: 1; + min-width: 3; +} + +/* Custom icon colors by file type */ +.folder-icon { + color: $warning; /* Folders are yellow/orange */ +} + +.document-icon { + color: $primary; /* Documents are blue */ +} + +.image-icon { + color: $success; /* Images are green */ +} + +.code-icon { + color: $accent-lighten-2; /* Code files are lighter accent color */ +} + +.archive-icon { + color: $error; /* Archives are red */ +} + #no_items_label { color: $text-muted; text-align: center; @@ -140,6 +179,7 @@ width: auto; height: 3; align: right middle; + margin-right: 1; } #button_container Button { diff --git a/maildir_gtd/app.py b/maildir_gtd/app.py index de6d730..2467d65 100644 --- a/maildir_gtd/app.py +++ b/maildir_gtd/app.py @@ -513,6 +513,7 @@ class EmailViewerApp(App): self.fetch_envelopes() async def action_archive(self) -> None: + # Remove from all data structures self.all_envelopes = [item for item in self.all_envelopes if item and item.get("id") != self.current_message_id] self.envelope_map.pop(self.current_message_id, None) self.envelope_index_map = {index: id for index, id in self.envelope_index_map.items() if id != self.current_message_id} @@ -523,13 +524,30 @@ class EmailViewerApp(App): k: v for k, v in self.message_body_cache.items() if k != self.current_message_id } self.total_messages = len(self.message_metadata) + + # Perform archive operation worker = archive_current(self) await worker.wait() - newmsg = self.all_envelopes[self.current_message_index] - if newmsg.get("type") == "header": - newmsg = self.all_envelopes[self.current_message_index + 1] - return - self.show_message(newmsg["id"]) + + # Get next message to display + try: + newmsg = self.all_envelopes[self.current_message_index] + # Skip headers + if newmsg.get("type") == "header": + if self.current_message_index + 1 < len(self.all_envelopes): + newmsg = self.all_envelopes[self.current_message_index + 1] + else: + # If we're at the end, go to the previous message + newmsg = self.all_envelopes[self.current_message_index - 1] + self.current_message_index -= 1 + + # Show the next message + if "id" in newmsg: + self.show_message(newmsg["id"]) + except (IndexError, KeyError): + # If no more messages, just reload envelopes + self.reload_needed = True + self.fetch_envelopes() def action_open(self) -> None: action_open(self) diff --git a/maildir_gtd/screens/DocumentViewer.py b/maildir_gtd/screens/DocumentViewer.py index eabf279..6751fa4 100644 --- a/maildir_gtd/screens/DocumentViewer.py +++ b/maildir_gtd/screens/DocumentViewer.py @@ -82,15 +82,15 @@ class DocumentViewerScreen(Screen): """Compose the document viewer screen.""" yield Container( Horizontal( + Container( + Button("✕", id="close_button"), + id="button_container" + ), Container( Label(f"Viewing: {self.item_name}", id="document_title"), Label(f'[link="{self.web_url}"]Open on Web[/link] | [link="{self.download_url}"]Download File[/link]', id="document_link"), ), - Container( - Button("Close", id="close_button"), - id="button_container" - ), - id="top_container" + id="top_container" ), ScrollableContainer( Markdown("", id="markdown_content"), diff --git a/utils/file_icons.py b/utils/file_icons.py new file mode 100644 index 0000000..c25f08e --- /dev/null +++ b/utils/file_icons.py @@ -0,0 +1,123 @@ +import os + +def get_file_icon(name, is_folder, with_color=False): + """Return a Nerd Font glyph based on file type or extension, optionally with color markup.""" + icon = "" + color = "" + + if is_folder: + icon = "\uf07b" # Nerd Font folder icon + color = "#FFB86C" # Folder color (orange/yellow) + else: + # Get the file extension + _, ext = os.path.splitext(name.lower()) + + # Map extensions to icons and colors + icons = { + # Documents + ".pdf": ("\uf1c1", "#8BE9FD"), # PDF - cyan + ".doc": ("\uf1c2", "#8BE9FD"), ".docx": ("\uf1c2", "#8BE9FD"), # Word - cyan + ".xls": ("\uf1c3", "#8BE9FD"), ".xlsx": ("\uf1c3", "#8BE9FD"), # Excel - cyan + ".ppt": ("\uf1c4", "#8BE9FD"), ".pptx": ("\uf1c4", "#8BE9FD"), # PowerPoint - cyan + ".txt": ("\uf15c", "#8BE9FD"), # Text - cyan + ".md": ("\uf48a", "#8BE9FD"), # Markdown - cyan + ".rtf": ("\uf15c", "#8BE9FD"), # RTF - cyan + ".odt": ("\uf1c2", "#8BE9FD"), # ODT - cyan + + # Code/Development + ".py": ("\ue73c", "#BD93F9"), # Python - purple + ".js": ("\ue781", "#BD93F9"), # JavaScript - purple + ".ts": ("\ue628", "#BD93F9"), # TypeScript - purple + ".html": ("\uf13b", "#BD93F9"), ".htm": ("\uf13b", "#BD93F9"), # HTML - purple + ".css": ("\uf13c", "#BD93F9"), # CSS - purple + ".json": ("\ue60b", "#BD93F9"), # JSON - purple + ".xml": ("\uf121", "#BD93F9"), # XML - purple + ".yml": ("\uf481", "#BD93F9"), ".yaml": ("\uf481", "#BD93F9"), # YAML - purple + ".sh": ("\uf489", "#BD93F9"), # Shell script - purple + ".bat": ("\uf489", "#BD93F9"), # Batch - purple + ".ps1": ("\uf489", "#BD93F9"), # PowerShell - purple + ".cpp": ("\ue61d", "#BD93F9"), ".c": ("\ue61e", "#BD93F9"), # C/C++ - purple + ".java": ("\ue738", "#BD93F9"), # Java - purple + ".rb": ("\ue739", "#BD93F9"), # Ruby - purple + ".go": ("\ue724", "#BD93F9"), # Go - purple + ".php": ("\ue73d", "#BD93F9"), # PHP - purple + + # Images + ".jpg": ("\uf1c5", "#50FA7B"), ".jpeg": ("\uf1c5", "#50FA7B"), # JPEG - green + ".png": ("\uf1c5", "#50FA7B"), # PNG - green + ".gif": ("\uf1c5", "#50FA7B"), # GIF - green + ".svg": ("\uf1c5", "#50FA7B"), # SVG - green + ".bmp": ("\uf1c5", "#50FA7B"), # BMP - green + ".tiff": ("\uf1c5", "#50FA7B"), ".tif": ("\uf1c5", "#50FA7B"), # TIFF - green + ".ico": ("\uf1c5", "#50FA7B"), # ICO - green + + # Media + ".mp3": ("\uf1c7", "#FF79C6"), # Audio - pink + ".wav": ("\uf1c7", "#FF79C6"), # Audio - pink + ".ogg": ("\uf1c7", "#FF79C6"), # Audio - pink + ".mp4": ("\uf1c8", "#FF79C6"), # Video - pink + ".avi": ("\uf1c8", "#FF79C6"), # Video - pink + ".mov": ("\uf1c8", "#FF79C6"), # Video - pink + ".mkv": ("\uf1c8", "#FF79C6"), # Video - pink + ".wmv": ("\uf1c8", "#FF79C6"), # Video - pink + + # Archives + ".zip": ("\uf1c6", "#FF5555"), # ZIP - red + ".rar": ("\uf1c6", "#FF5555"), # RAR - red + ".7z": ("\uf1c6", "#FF5555"), # 7z - red + ".tar": ("\uf1c6", "#FF5555"), ".gz": ("\uf1c6", "#FF5555"), # TAR/GZ - red + ".bz2": ("\uf1c6", "#FF5555"), # BZ2 - red + + # Others + ".exe": ("\uf085", "#F8F8F2"), # Executable - white + ".iso": ("\uf0a0", "#F8F8F2"), # ISO - white + ".dll": ("\uf085", "#F8F8F2"), # DLL - white + ".db": ("\uf1c0", "#F8F8F2"), # Database - white + ".sql": ("\uf1c0", "#F8F8F2"), # SQL - white + } + + # Set default icon and color for unknown file types + icon = "\uf15b" # Default file icon + color = "#F8F8F2" # Default color (white) + + # Get icon and color from the map if the extension exists + if ext in icons: + icon, color = icons[ext] + + # Return either plain icon or with color markup + if with_color: + return f"[{color}]{icon}[/]" + else: + return icon + +def get_icon_class(name, is_folder): + """Determine CSS class for the icon based on file type.""" + if is_folder: + return "folder-icon" + + # Get the file extension + _, ext = os.path.splitext(name.lower()) + + # Document files + if ext in [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".rtf", ".odt"]: + return "document-icon" + + # Code files + elif ext in [".py", ".js", ".ts", ".html", ".htm", ".css", ".json", ".xml", ".yml", ".yaml", + ".sh", ".bat", ".ps1", ".cpp", ".c", ".java", ".rb", ".go", ".php"]: + return "code-icon" + + # Image files + elif ext in [".jpg", ".jpeg", ".png", ".gif", ".svg", ".bmp", ".tiff", ".tif", ".ico"]: + return "image-icon" + + # Archive files + elif ext in [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"]: + return "archive-icon" + + # Media files + elif ext in [".mp3", ".wav", ".ogg", ".mp4", ".avi", ".mov", ".mkv", ".wmv"]: + return "media-icon" + + # Default for other file types + return ""