summaryrefslogtreecommitdiff
path: root/tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'tui.py')
-rw-r--r--tui.py326
1 files changed, 326 insertions, 0 deletions
diff --git a/tui.py b/tui.py
new file mode 100644
index 0000000..1c84892
--- /dev/null
+++ b/tui.py
@@ -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()