summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrett Curran <brettjcurran@gmail.com>2026-02-12 09:45:05 +1100
committerBrett Curran <brettjcurran@gmail.com>2026-02-12 09:45:05 +1100
commitb1cff893077f8042c57fa387d057e644877ccc88 (patch)
treeaa0053252cd0b6e7fca2292be1e3f02ebf6dabe6
init
-rw-r--r--.gitignore15
-rw-r--r--.python-version1
-rw-r--r--README.md0
-rw-r--r--pyproject.toml9
-rw-r--r--tui.py326
-rw-r--r--uv.lock130
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",
+]
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()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..d3dc45e
--- /dev/null
+++ b/uv.lock
@@ -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" }]