From 00a8bae652f3c3751089578494bb5c23601d5719 Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Fri, 10 Jul 2026 17:56:30 +0300 Subject: [PATCH] [minimal][tui] added tui capabilities :Release Notes: - Why? For which reason? Just because... :Detailed Notes: - moved minimal as subcommand to argparse for dividing arguments specific for some modules only :Testing Performed: - Direct launch with tui enabled :QA Notes: - :Issues Addressed: - --- requirements.txt | 11 ++ src/ts-example/__main__.py | 26 ++++- src/ts-example/command_runner.py | 10 ++ src/ts-example/log_conf.py | 24 ++-- src/ts-example/ts_minimal.py | 75 +++++++++---- src/ts-example/tui.py | 181 +++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 35 deletions(-) create mode 100644 src/ts-example/command_runner.py create mode 100644 src/ts-example/tui.py diff --git a/requirements.txt b/requirements.txt index 703674a..792d7ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,14 @@ +linkify-it-py==2.1.0 +markdown-it-py==4.2.0 +mdit-py-plugins==0.6.1 +mdurl==0.1.2 +platformdirs==4.10.0 +Pygments==2.20.0 +rich==15.0.0 +textual==8.2.8 +textual-autocomplete==4.0.6 tree-sitter==0.26.0 tree-sitter-cpp==0.23.4 tree-sitter-python==0.25.0 +typing_extensions==4.16.0 +uc-micro-py==2.0.0 diff --git a/src/ts-example/__main__.py b/src/ts-example/__main__.py index 57842f3..44c1ab8 100644 --- a/src/ts-example/__main__.py +++ b/src/ts-example/__main__.py @@ -1,6 +1,8 @@ import logging +from typing import Optional import ts_minimal as tsmin +import tui from arg_conf import parser, setup_parser from log_conf import logger, logger_setup @@ -22,20 +24,38 @@ def main_arguments(): parser.add_argument( "-l", "--log-file", help="File in which log messages will go", default="ts.log" ) + parser.add_argument( + "-t", + "--tui", + help="Enable TUI for this run", + action="store_true", + default=False, + ) parser.add_argument("-s", "--severity", help="Log severity level", default="DEBUG") tsmin.arguments() args = parser.parse_args() - return args + return args def main(): global logger args = main_arguments() - logger_setup("Seminar-Test", parse_severity(args.severity)) + handler = None + app: Optional[tui.ConsoleApp] = None + if args.tui: + app = tui.ConsoleApp() + handler = tui.RichLogHandler(app) + + logger_setup("Seminar-Test", parse_severity(args.severity), handler) logger.info("Hi!") - tsmin.simple_file() + if args.command == 'minimal' and app is not None: + runner = tsmin.SingleFileParser(args) + app.add_executor(runner) + + if args.tui and app is not None: + app.run() pass diff --git a/src/ts-example/command_runner.py b/src/ts-example/command_runner.py new file mode 100644 index 0000000..93d07c7 --- /dev/null +++ b/src/ts-example/command_runner.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +class ICommandRunner(ABC): + @abstractmethod + def run_command(self, command: str): + pass + + @abstractmethod + def get_commands(self) -> list[str]: + pass diff --git a/src/ts-example/log_conf.py b/src/ts-example/log_conf.py index 009bc6e..b50d82b 100644 --- a/src/ts-example/log_conf.py +++ b/src/ts-example/log_conf.py @@ -1,24 +1,22 @@ import logging +from typing import Optional logger = logging.getLogger("Seminar-Test") -def logger_setup(app_name: str, logging_level) -> logging.Logger: +def logger_setup(app_name: str, logging_level, handler: Optional[logging.Handler]) -> logging.Logger: logger.name = app_name logger.handlers.clear() logger.setLevel(logging_level) - # # create file handler which logs even debug messages - # fh = logging.FileHandler("spam.log") - # fh.setLevel(logging_level) - # create console handler with a higher log level - ch = logging.StreamHandler() - ch.setLevel(logging_level) - # create formatter and add it to the handlers formatter = logging.Formatter("[%(asctime)s] [%(name)s:%(levelname)s] %(message)s") - # fh.setFormatter(formatter) - ch.setFormatter(formatter) - # add the handlers to the logger - # logger.addHandler(fh) - logger.addHandler(ch) + if handler is None: + ch = logging.StreamHandler() + ch.setLevel(logging_level) + ch.setFormatter(formatter) + logger.addHandler(ch) + else: + handler.setLevel(logging_level) + handler.setFormatter(formatter) + logger.addHandler(handler) return logger diff --git a/src/ts-example/ts_minimal.py b/src/ts-example/ts_minimal.py index f0a6bf8..f87fd8f 100644 --- a/src/ts-example/ts_minimal.py +++ b/src/ts-example/ts_minimal.py @@ -1,35 +1,72 @@ -import logging -from sys import stdout +import tempfile +from command_runner import ICommandRunner from arg_conf import parser as arg_parser from log_conf import logger import tree_sitter_python as tspython import tree_sitter_cpp as tscpp -from tree_sitter import Language, Parser +from tree_sitter import Language, Parser, Tree PY_LANGUAGE = Language(tspython.language()) CPP_LANGUAGE = Language(tscpp.language()) def arguments(): global arg_parser - arg_parser.add_argument("--language", help="Setup parsing language", default="Python") - arg_parser.add_argument("--file", "-f", help="File to parse", required=True) + subparsers = arg_parser.add_subparsers( + dest="command", + required=True, + ) + + module_parser = subparsers.add_parser("minimal") + + module_parser.add_argument("--language", help="Setup parsing language", default="Python") + module_parser.add_argument("--file", "-f", help="File to parse", required=True) logger.debug("Added Tree Sitter Minimal Example Arguments to the list") +class SingleFileParser(ICommandRunner): + def __init__ (self, args): + self.tree: Tree + self.parser: Parser + if args.language == 'Python': + self.parser = Parser(PY_LANGUAGE) + elif args.language == 'C++': + self.parser = Parser(CPP_LANGUAGE) + else: + self.parser = Parser(PY_LANGUAGE) + args.language = 'Python' + logger.info(f"Parser is installed to {args.language}") + + + def get_usages(self): + pass + + def read_file(self, filename: str): + with open(filename, 'rb') as file: + source_code = file.read() + self.tree = self.parser.parse(source_code) + + def dump_tree(self): + with tempfile.TemporaryFile(mode="w+b", buffering=0) as output: + self.tree.print_dot_graph(output) + + output.seek(0) + graph = output.read().decode("utf-8", errors="replace") + + logger.info("Tree graph:\n%s", graph.rstrip()) + + def run_command(self, command: str): + global arg_parser + args = arg_parser.parse_args() + if command == "simple_file": + self.read_file(args.file) + self.dump_tree() + + def get_commands(self) -> list[str]: + return ["simple_file"] + def simple_file(): global arg_parser args = arg_parser.parse_args() - parser: Parser - if args.language == 'Python': - parser = Parser(PY_LANGUAGE) - elif args.language == 'C++': - parser = Parser(CPP_LANGUAGE) - else: - parser = Parser(PY_LANGUAGE) - args.language = 'Python' - logger.info(f"Parser is installed to {args.language}") - - with open(args.file, 'rb') as file: - source_code = file.read() - tree = parser.parse(source_code) - tree.print_dot_graph(stdout) + tree_parser = SingleFileParser(args) + tree_parser.read_file(args.file) + tree_parser.dump_tree() diff --git a/src/ts-example/tui.py b/src/ts-example/tui.py new file mode 100644 index 0000000..9212795 --- /dev/null +++ b/src/ts-example/tui.py @@ -0,0 +1,181 @@ +import logging + +from textual import on +from textual.app import App, ComposeResult +from command_runner import ICommandRunner +from textual.message import Message +from textual.widgets import Input, RichLog +from textual.geometry import Offset, Region, Spacing +from textual_autocomplete import AutoComplete + +class AutoCompleteAbove(AutoComplete): + def _align_to_target(self) -> None: + """Place the autocomplete list above the input cursor.""" + cursor_x, cursor_y = self.target.cursor_screen_offset + + dropdown = self.option_list + width, height = dropdown.outer_size + + # Align horizontally like the original implementation, + # but place the bottom of the dropdown above the input. + desired_x = cursor_x - 1 + desired_y = cursor_y - height + + x, y, _, _ = Region( + desired_x, + desired_y, + width, + height, + ).constrain( + "inside", + "none", + Spacing.all(0), + self.screen.scrollable_content_region, + ) + + self.absolute_offset = Offset(x, y) + +class LogLine(Message): + """A line to display in the TUI output pane.""" + + def __init__(self, text: str) -> None: + self.text = text + super().__init__() + + +class RichLogHandler(logging.Handler): + """Send standard logging records to the Textual app.""" + + def __init__(self, app: "ConsoleApp") -> None: + super().__init__() + self.app = app + + def emit(self, record: logging.LogRecord) -> None: + try: + text = self.format(record) + + # post_message is safe even when logging happens + # in another thread. + self.app.post_message(LogLine(text)) + except Exception: + self.handleError(record) + + +class ConsoleApp(App[None]): + AUTO_FOCUS = "#command" + + CSS = """ + Screen { + layout: vertical; + } + + #output { + height: 1fr; + border: round $primary; + } + + #command { + dock: bottom; + height: 3; + } + + AutoCompleteAbove AutoCompleteList { + max-height: 10; + padding: 0 1; + border: round $primary; + } + """ + + def compose(self) -> ComposeResult: + yield RichLog( + id="output", + wrap=True, + auto_scroll=True, + markup=False, + max_lines=2_000, + ) + textInput = Input( + placeholder="Type a command and press Enter", + id="command", + ) + candidates = self.get_commands() + if self.runner is not None: + candidates += self.runner.get_commands() + yield textInput + yield AutoCompleteAbove( + textInput, + candidates=candidates + ) + + def add_executor(self, runner: ICommandRunner): + self.runner = runner + + def on_mount(self) -> None: + self._log_handler = RichLogHandler(self) + self._log_handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) + ) + + # Replace terminal-based logging handlers while the TUI is running. + self._root_logger = logging.getLogger() + self._previous_handlers = self._root_logger.handlers[:] + self._previous_level = self._root_logger.level + + for handler in self._previous_handlers: + self._root_logger.removeHandler(handler) + + self._root_logger.addHandler(self._log_handler) + self._root_logger.setLevel(logging.INFO) + + logging.info("Application started") + + def on_unmount(self) -> None: + # Restore the application's previous logging configuration. + self._root_logger.removeHandler(self._log_handler) + + for handler in self._previous_handlers: + self._root_logger.addHandler(handler) + + self._root_logger.setLevel(self._previous_level) + + @on(LogLine) + def display_log_line(self, message: LogLine) -> None: + self.query_one("#output", RichLog).write(message.text) + + @on(Input.Submitted, "#command") + def handle_command(self, event: Input.Submitted) -> None: + command = event.value.strip() + event.input.clear() + + if not command: + return + + self.post_message(LogLine(f"> {command}")) + + if command in {"quit", "exit"}: + self.exit() + return + + try: + self.run_command(command) + except Exception: + logging.exception("Command failed") + + def get_commands(self) -> list[str]: + return ["help", "error", "quit", "exit"] + + def run_command(self, command: str) -> None: + """Replace this with your application logic.""" + + if command == "help": + logging.info("Commands: help, error, quit") + + elif command == "error": + raise RuntimeError("Example failure") + + else: + logging.info(f"Delegated {command} to runner") + self.runner.run_command(command)