[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:
-
This commit is contained in:
Artur Mukhamadiev 2026-07-10 17:56:30 +03:00
parent c3b831d519
commit 00a8bae652
6 changed files with 292 additions and 35 deletions

View File

@ -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==0.26.0
tree-sitter-cpp==0.23.4 tree-sitter-cpp==0.23.4
tree-sitter-python==0.25.0 tree-sitter-python==0.25.0
typing_extensions==4.16.0
uc-micro-py==2.0.0

View File

@ -1,6 +1,8 @@
import logging import logging
from typing import Optional
import ts_minimal as tsmin import ts_minimal as tsmin
import tui
from arg_conf import parser, setup_parser from arg_conf import parser, setup_parser
from log_conf import logger, logger_setup from log_conf import logger, logger_setup
@ -22,20 +24,38 @@ def main_arguments():
parser.add_argument( parser.add_argument(
"-l", "--log-file", help="File in which log messages will go", default="ts.log" "-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") parser.add_argument("-s", "--severity", help="Log severity level", default="DEBUG")
tsmin.arguments() tsmin.arguments()
args = parser.parse_args() args = parser.parse_args()
return args
return args
def main(): def main():
global logger global logger
args = main_arguments() 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!") 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 pass

View File

@ -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

View File

@ -1,24 +1,22 @@
import logging import logging
from typing import Optional
logger = logging.getLogger("Seminar-Test") 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.name = app_name
logger.handlers.clear() logger.handlers.clear()
logger.setLevel(logging_level) 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") formatter = logging.Formatter("[%(asctime)s] [%(name)s:%(levelname)s] %(message)s")
# fh.setFormatter(formatter) if handler is None:
ch.setFormatter(formatter) ch = logging.StreamHandler()
# add the handlers to the logger ch.setLevel(logging_level)
# logger.addHandler(fh) ch.setFormatter(formatter)
logger.addHandler(ch) logger.addHandler(ch)
else:
handler.setLevel(logging_level)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger return logger

View File

@ -1,35 +1,72 @@
import logging import tempfile
from sys import stdout from command_runner import ICommandRunner
from arg_conf import parser as arg_parser from arg_conf import parser as arg_parser
from log_conf import logger from log_conf import logger
import tree_sitter_python as tspython import tree_sitter_python as tspython
import tree_sitter_cpp as tscpp import tree_sitter_cpp as tscpp
from tree_sitter import Language, Parser from tree_sitter import Language, Parser, Tree
PY_LANGUAGE = Language(tspython.language()) PY_LANGUAGE = Language(tspython.language())
CPP_LANGUAGE = Language(tscpp.language()) CPP_LANGUAGE = Language(tscpp.language())
def arguments(): def arguments():
global arg_parser global arg_parser
arg_parser.add_argument("--language", help="Setup parsing language", default="Python") subparsers = arg_parser.add_subparsers(
arg_parser.add_argument("--file", "-f", help="File to parse", required=True) 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") 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(): def simple_file():
global arg_parser global arg_parser
args = arg_parser.parse_args() 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}") tree_parser = SingleFileParser(args)
tree_parser.read_file(args.file)
with open(args.file, 'rb') as file: tree_parser.dump_tree()
source_code = file.read()
tree = parser.parse(source_code)
tree.print_dot_graph(stdout)

181
src/ts-example/tui.py Normal file
View File

@ -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)