diff options
| author | Brett Curran <brettjcurran@gmail.com> | 2026-02-12 09:45:05 +1100 |
|---|---|---|
| committer | Brett Curran <brettjcurran@gmail.com> | 2026-02-12 09:45:05 +1100 |
| commit | b1cff893077f8042c57fa387d057e644877ccc88 (patch) | |
| tree | aa0053252cd0b6e7fca2292be1e3f02ebf6dabe6 | |
init
| -rw-r--r-- | .gitignore | 15 | ||||
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | README.md | 0 | ||||
| -rw-r--r-- | pyproject.toml | 9 | ||||
| -rw-r--r-- | tui.py | 326 | ||||
| -rw-r--r-- | uv.lock | 130 |
6 files changed, 481 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..337a499 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Local data +.publish_history.json +mock_campaign/ +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b53adf9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "vtt-publish" +version = "0.1.0" +description = "A TUI application designed for publishing files to a vtt server directory" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "textual>=7.5.0", +] @@ -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() @@ -0,0 +1,130 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "textual" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + +[[package]] +name = "vtt-publish" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "textual" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=7.5.0" }] |
