from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.widgets import Header, Footer, DirectoryTree, DataTable, Log, Static from textual.binding import Binding from textual import on from pathlib import Path import os import subprocess def get_git_version(): try: # git describe --tags --always --dirty # Returns things like: "v1.0.2-4-g9a2b3c" or "v1.0.2" return subprocess.check_output( ["git", "describe", "--tags", "--always", "--dirty"], stderr=subprocess.DEVNULL ).decode("utf-8").strip() except (subprocess.CalledProcessError, FileNotFoundError): return "dev" # --- MOCK DATA GENERATOR (Creates fake files for testing) --- def create_mock_files(): root = Path("mock_campaign") root.mkdir(exist_ok=True) # Create a structure matching your mockup dirs = [ "artwork/scenes/4_belcorras_retreat/lasdas_lament", "artwork/scenes/4_belcorras_retreat/affinity", "artwork/tokens/creatures", "artwork/handouts/letters" ] for d in dirs: full_path = root / d full_path.mkdir(parents=True, exist_ok=True) # Create fake webp files if "lasdas_lament" in str(full_path): (full_path / "lasdas_lament.webp").touch() (full_path / "lasdas_lament_map_bg.webp").touch() if "creatures" in str(full_path): (full_path / "nhakazarin.webp").touch() class CampaignTree(DirectoryTree): """A DirectoryTree with Vim-like navigation.""" # TODO: Bindings do not actually work to navigate the tree. BINDINGS = [ Binding("h", "collapse_or_parent", "Collapse / Up"), Binding("l", "expand_node", "Expand"), # j and k are built-in to DirectoryTree for navigation, so we don't need to add them ] def action_collapse_or_parent(self): """Collapse node or jump to parent.""" node = self.cursor_node if not node: return if node.is_expanded: node.collapse() elif node.parent: # Jump to parent logic target_node = node.parent # Find the line number of the parent current_line = self.cursor_line while current_line > 0: current_line -= 1 if self.get_node_at_line(current_line) == target_node: self.cursor_line = current_line self.scroll_to_line(current_line) break def action_expand_node(self): """Expand the current node.""" node = self.cursor_node if node and node.allow_expand and not node.is_expanded: node.expand() class FileTable(DataTable): """A DataTable with custom file selection actions.""" # Define our constants here for easy access ICON_CHECKED = "✔" ICON_UNCHECKED = "✘" BINDINGS = [ Binding("space", "toggle_select", "Select/Deselect"), Binding("a", "toggle_all", "Select All"), # Category Shortcuts Binding("s", "set_category('Scenes')", "Set: Scenes"), Binding("t", "set_category('Tokens')", "Set: Tokens"), Binding("m", "set_category('Maps')", "Set: Maps"), Binding("h", "set_category('Handouts')", "Set: Handouts"), # 'h' is safe here! ] def action_toggle_select(self): try: row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) current_val = str(self.get_cell(row_key, "Select")) new_val = self.ICON_CHECKED if current_val == self.ICON_UNCHECKED else self.ICON_UNCHECKED self.update_cell(row_key, "Select", new_val) # We can bubble a log message event up if we want, or just print for now except Exception: pass def action_toggle_all(self): all_checked = True for row_key in self.rows: if self.get_cell(row_key, "Select") == self.ICON_UNCHECKED: all_checked = False break target_val = self.ICON_UNCHECKED if all_checked else self.ICON_CHECKED for row_key in self.rows: self.update_cell(row_key, "Select", target_val) def action_set_category(self, category_name: str): try: row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) self.update_cell(row_key, "Category", category_name) except: pass # --- THE TUI APPLICATION --- class PublisherApp(App): """The Terminal Publishing Tool""" CSS = """ Screen { layout: vertical; background: #0f1f18; /* Deep dark green background */ } /* HEADER & FOOTER */ Header { dock: top; background: #1a3b2e; color: #4caf50; } Footer { dock: bottom; background: #1a3b2e; color: #4caf50; } /* MAIN CONTAINER */ #main_container { height: 70%; layout: horizontal; border-bottom: solid #4caf50; } /* LEFT PANE (Directory) */ #left_pane { width: 30%; height: 100%; border-right: solid #4caf50; background: #0f1f18; } /* STYLE CLASS FOR THE STATIC HEADER */ .pane-header { background: #4caf50; color: #0f1f18; /* Dark text on green background (Reverse look) */ text-style: bold; padding-left: 1; } DirectoryTree { background: #0f1f18; color: #8bc34a; /* Light green text */ border: hidden; /* We use the container for the border */ } DirectoryTree:focus { background: #162d23; } /* RIGHT PANE (Table) */ #right_pane { width: 70%; height: 100%; background: #0f1f18; } DataTable { background: #0f1f18; color: #a5d6a7; scrollbar-gutter: stable; } DataTable:focus { border: solid #4caf50; /* Highlight when active */ } /* Highlight the selected row in the table */ DataTable > .datatable--cursor { background: #2e7d32; color: white; } /* LOG PANE */ #log_pane { height: 30%; background: #0a1410; color: #81c784; padding: 1; } """ # KEY BINDINGS BINDINGS = [ ("tab", "toggle_focus", "Switch Pane"), ("p", "publish", "Publish Selected"), ("r", "refresh", "Refresh Scan"), ("q", "quit", "Quit"), ] def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header(show_clock=True) # Main split view with Container(id="main_container"): # Left Pane: Directory Tree with Container(id="left_pane"): yield Static(" Directory Browser", classes="pane-header") # Start at current directory or mock directory start_path = "./mock_campaign" if os.path.exists("./mock_campaign") else "." yield DirectoryTree(start_path, id="tree_view") # Right Pane: Data Table with Container(id="right_pane"): yield FileTable(id="file_table") # Bottom Pane: Log log_widget = Log(id="log_pane", highlight=True) log_widget.can_focus = False yield log_widget yield Footer() def on_mount(self) -> None: """Called when app starts.""" version_str = get_git_version() self.title = f"FVTT PUBLISH v{version_str}" table = self.query_one(DataTable) table.cursor_type = "row" # DEFINING EXPLICIT KEYS HERE IS THE MAGIC SAUCE table.add_column("Select", key="Select") table.add_column("Status", key="Status") table.add_column("Category", key="Category") table.add_column("Filename", key="Filename") table.add_column("Location", key="Location") self.log_message("System initialized.") if not os.path.exists("mock_campaign"): create_mock_files() try: self.query_one(CampaignTree).reload() except: pass # --- ACTIONS & LOGIC --- def log_message(self, msg: str): """Write to the bottom log window.""" log = self.query_one(Log) log.write_line(f"> {msg}") def action_toggle_focus(self): """Switch focus between Tree and Table.""" if self.query_one("#tree_view").has_focus: self.query_one("#file_table").focus() else: self.query_one("#tree_view").focus() @on(DirectoryTree.DirectorySelected) def handle_directory_click(self, event: DirectoryTree.DirectorySelected): """When a directory is selected in the tree, scan it.""" path = event.path self.log_message(f"Scanning directory: {path}") self.populate_table(path) def populate_table(self, root_path): """Clears and refills the table with .webp files from the path.""" table = self.query_one(FileTable) table.clear() root = Path(root_path) # Simple recursive scan mock for path in root.rglob("*.webp"): # Simple heuristic mock cat = "Scenes" if "creatures" in str(path): cat = "Tokens" if "handouts" in str(path): cat = "Handouts" try: display_location = str(path.parent.relative_to(root)) except ValueError: display_location = str(path.parent) # Add row table.add_row( table.ICON_UNCHECKED, # Select "[NEW]", # Status cat, # Category path.name, # Filename display_location # Location ) if __name__ == "__main__": app = PublisherApp() app.run()