diff options
Diffstat (limited to 'tui.py')
| -rw-r--r-- | tui.py | 326 |
1 files changed, 326 insertions, 0 deletions
@@ -0,0 +1,326 @@ +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() |
