# Save this in your directory root as: tactility_tui.py import json import os import re import sys import subprocess import time import urllib.request import zipfile import tarfile from pathlib import Path from urllib.parse import urlparse from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.widgets import Footer, Button, Log, ListView, ListItem, Label, Select, TabbedContent, TabPane, Input, Checkbox from textual.binding import Binding import _device_host ttbuild_path = os.path.abspath(".tactility") ttbuild_version = "6.0.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 ttport = 6666 http_timeout_seconds = 10 install_timeout_seconds = 120 # Active target logging pointers to feed real-time outputs straight to UI containers active_ui_logger = None # app.py does the actual work: it is next to this script APP_TOOL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.py") ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") def resolve_espressif_environment(): """Captures Espressif's custom paths and mirrors them globally.""" activation_profile = r"C:\Espressif\tools\Microsoft.v6.1.PowerShell_profile.ps1" if not os.path.isfile(activation_profile): return try: cmd = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", activation_profile, "-e"] result = subprocess.run(cmd, capture_output=True, text=True, check=True) for line in result.stdout.splitlines(): line = line.strip() if not line or "=" not in line: continue key, sep, value = line.partition("=") if key.strip() == "PATH": os.environ["PATH"] = value.strip() + os.pathsep + os.environ.get("PATH", "") else: os.environ[key.strip()] = value.strip() except Exception: pass resolve_espressif_environment() # region Core Action Logic Engines (Imported from Original Utility Script) def tui_print(message): """Intercepts and streams prints directly into active log panel views.""" global active_ui_logger if active_ui_logger: active_ui_logger.write_line(str(message)) else: print(message) def download_file(url, filepath): tui_print(f"āŒ› Downloading from {url}...") try: request = urllib.request.Request(url, data=None, headers={"User-Agent": f"Tactility Build Tool {ttbuild_version}"}) with urllib.request.urlopen(request, timeout=30) as response, open(filepath, mode="wb") as file: file.write(response.read()) return True except Exception as e: tui_print(f"āŒ Failed to fetch URL {url}: {e}") return False def read_properties_file(path): properties = {} if not os.path.isfile(path): return properties with open(path, "r") as file: for line in file: stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in line: continue key, sep, value = line.partition("=") properties[key.strip()] = value.strip() return properties class SemanticVersion: def __init__(self, major, minor, patch, tag=None): self.major, self.minor, self.patch, self.tag = major, minor, patch, tag @staticmethod def parse(v_str): match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", v_str) if not match: return SemanticVersion(0,0,0) return SemanticVersion(int(match.group(1)), int(match.group(2)), int(match.group(3)), match.group(4)) def _numeric(self): return (self.major, self.minor, self.patch) def is_older_release_of(self, other): return self._numeric() < other._numeric() # The last output line of the last subprocess, which tells why it failed last_output_line = "" def run_native_subprocess(command_args, target_cwd): """Executes active compiler builders while flushing out standard line streams instantly.""" global last_output_line last_output_line = "" try: shell_needed = sys.platform == "win32" process = subprocess.Popen( command_args, # The dashboard owns the terminal, so tools can't ask questions stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=target_cwd, shell=shell_needed, text=True, bufsize=1 # Forces immediate line un-buffering rules down to stdout ) while True: line = process.stdout.readline() if not line and process.poll() is not None: break if line: # Progress lines are rewritten with a carriage return in a terminal, and colored text = ANSI_ESCAPE_PATTERN.sub("", line.rstrip("\r\n")).split("\r")[-1] tui_print(text) if text.strip(): last_output_line = text.strip() process.wait() return process.returncode == 0 except Exception as e: tui_print(f"āŒ Subprocess crash encountered: {e}") return False #endregion Core Action Logic Engines class TactilityTUI(App): """The master native asynchronous dashboard controlling the compilation ecosystem.""" CSS = """ Screen { background: #121212; } #sidebar { width: 32; background: #1a1a1a; border-right: solid #333333; padding: 1; } #main_content { padding: 1; } #control_panel { height: 7; margin-bottom: 1; background: #161616; border: solid #333333; padding: 1 1 0 1; } #network_settings { margin-left: 4; width: 35; } .section_title { text-style: bold; color: #00ffcc; margin-bottom: 0; } Horizontal Button { margin-right: 1; min-width: 14; height: 3; } Log { background: #1e1e1e; color: #ffffff; overflow-x: hidden; text-wrap: wrap; } TabbedContent, TabPane, ContentSwitcher { padding: 0; margin: 0; } TabbedContent { margin-top: 1; height: 1fr; } Input { height: 3; border: tall #333333; } ListItem { padding: 1; } ListItem:hover { background: #262626; } ListItem.--on-selection { background: #00ffcc; color: #121212; text-style: bold; } Checkbox { background: transparent; border: none; padding: 0; margin-bottom: 0; } """ BINDINGS = [ Binding("f5", "action_pipeline('build')", "Build (F5)"), Binding("f6", "action_pipeline('install')", "Install (F6)"), Binding("f7", "action_pipeline('run')", "Run (F7)"), Binding("f8", "action_pipeline('bir')", "Full Cycle BIR (F8)"), Binding("f9", "action_pipeline('clean')", "Clean Folders"), Binding("q", "quit", "Quit Dashboard") ] def __init__(self): super().__init__() self.selected_app_path = None self.apps_list = [] def scan_apps_directory(self): apps_root = Path("Apps") if not apps_root.exists(): return [] return sorted([p for p in apps_root.iterdir() if p.is_dir() and (p / "manifest.properties").exists()], key=lambda x: x.name) def compose(self) -> ComposeResult: with Horizontal(): with Vertical(id="sidebar"): yield Label("šŸ“ APP WORKSPACES", classes="section_title") self.list_view = ListView(id="app_selector") yield self.list_view yield Label("\nāš™ļø TARGET ARCH", classes="section_title") yield Select([("Auto-detect (Manifest)", "auto"), ("ESP32", "esp32"), ("ESP32-C6", "esp32c6"), ("ESP32-P4", "esp32p4"), ("ESP32-S3", "esp32s3"), ("POSIX", "posix-x86_64")], value="auto", id="arch_selector") yield Label("\nšŸ› ļø BUILD MODIFIERS", classes="section_title") yield Checkbox("Skip compiler steps", id="chk_skip_build") yield Checkbox("Verbose log output", id="chk_verbose") with Vertical(id="main_content"): with Horizontal(id="control_panel"): with Vertical(): yield Label("⚔ ACTIVE TASK CONTROLS", classes="section_title") with Horizontal(): yield Button("Build", id="btn_build", variant="success") yield Button("Install", id="btn_install", variant="warning") yield Button("Run", id="btn_run", variant="primary") yield Button("Clean", id="btn_clean", variant="error") with Vertical(id="network_settings"): yield Label("🌐 REMOTE TARGET HOST", classes="section_title") yield Input(value=_device_host.read_device_host() or "", placeholder="e.g. 192.168.1.50", id="host_input") with TabbedContent(id="tabs"): with TabPane("šŸ“‹ System Output Feed", id="tab_system"): yield Log(id="system_log") with TabPane("šŸ› ļø Active Build Pipeline Monitor", id="tab_pipeline"): yield Log(id="pipeline_log") yield Footer() def on_mount(self) -> None: self.apps_list = self.scan_apps_directory() if not self.apps_list: return for app_path in self.apps_list: self.list_view.append(ListItem(Label(f"šŸ”¹ {app_path.name}"), id=f"app_{app_path.name}".replace(" ", "_"))) self.list_view.index = 0 self.selected_app_path = self.apps_list[0] def on_list_view_selected(self, event: ListView.Selected) -> None: idx = self.list_view.index if idx is not None and idx < len(self.apps_list): self.selected_app_path = self.apps_list[idx] self.query_one("#system_log", Log).write_line(f"šŸŽÆ Switched app context to: {str(self.selected_app_path)}") def on_button_pressed(self, event: Button.Pressed) -> None: action_map = {"btn_build": "build", "btn_install": "install", "btn_run": "run", "btn_clean": "clean"} a = action_map.get(event.button.id) if a: self.execute_task_pipeline(a) def action_pipeline(self, action: str) -> None: self.execute_task_pipeline(action) def execute_task_pipeline(self, action: str) -> None: """Handles safe, non-blocking task execution by separating UI management from compilation workers.""" pipeline_log = self.query_one("#pipeline_log", Log) tabs = self.query_one("#tabs", TabbedContent) # app.py falls back to the host of "tt host" when this is empty host_ip = self.query_one("#host_input", Input).value.strip() # --- SAFE MAIN THREAD UI MANAGEMENT --- # Mutating UI properties here on the main event loop avoids the RuntimeError entirely tabs.active = "tab_pipeline" pipeline_log.clear() # Capture all layout checkbox states safely before spawning the thread skip_build = self.query_one("#chk_skip_build", Checkbox).value verbose_mode = self.query_one("#chk_verbose", Checkbox).value arch_select = self.query_one("#arch_selector", Select).value # Global logging variable reassignment global active_ui_logger active_ui_logger = pipeline_log # --- SPAWN THE THREAD WORKER --- # Pass captured primitive values explicitly to isolate the thread worker from UI widgets # The selection can change while the worker runs selected_app_path = self.selected_app_path self.run_worker( lambda: self._native_thread_executor(action, selected_app_path, host_ip, skip_build, verbose_mode, arch_select), thread=True ) def _native_thread_executor(self, action: str, selected_app_path, host_ip: str, skip_build: bool, verbose_mode: bool, arch_select: str) -> None: """Isolated background thread handler running purely synchronous script and compiler operations.""" # Use safe tui_print wrappers to safely proxy text calls to the log widget across threads tui_print(f"āš™ļø Running native compiler pipeline operation: '{action.upper()}' on workspace path '{str(selected_app_path)}'...") app_dir = os.path.abspath(str(selected_app_path)) manifest = read_properties_file(os.path.join(app_dir, "manifest.properties")) if not manifest: tui_print("āŒ Failed to parse manifest.properties!") return # Ensure platform un-nesting evaluates strictly to a flat string token if arch_select == "auto": raw_platforms = manifest.get("target.platforms", "esp32s3").split(",") platform = raw_platforms[0].strip() # Default cleanly to the first listed target architecture else: platform = arch_select def run_app_tool(arguments): # Run from here with --path, so app.py finds the host of "tt host" in this directory command = [sys.executable, APP_TOOL_PATH, action, "--path", app_dir, *arguments] if verbose_mode: command.append("--verbose") return run_native_subprocess(command, os.getcwd()) host_arguments = ["--host", host_ip] if host_ip else [] platform_arguments = ["--architecture", platform] start_time = time.time() if action == "clean": success = run_app_tool([]) elif action == "build": success = run_app_tool(platform_arguments + (["--skip-build"] if skip_build else [])) elif action in ("install", "run"): success = run_app_tool(host_arguments) elif action in ("bir", "brrr"): success = run_app_tool(host_arguments + platform_arguments) else: success = False duration = time.time() - start_time # Use call_from_thread to safely update the system_log widget from this background thread if success: tui_print(f"\nāœ… Task pipeline finished successfully in {duration:.1f}s!") self.call_from_thread(self.query_one("#system_log", Log).write_line, f"āœ… Active job completed successfully: '{action}' ({duration:.1f}s)") else: tui_print("\nāŒ Pipeline operation failed or sub-process exited with an error state.") reason = f": {last_output_line}" if last_output_line else "" self.call_from_thread(self.query_one("#system_log", Log).write_line, f"āŒ Active job failed: '{action}'{reason}") if __name__ == "__main__": import _bootstrap _bootstrap.run() TactilityTUI().run()