From 0965d0486838dd56c04827078a941845f6113f38 Mon Sep 17 00:00:00 2001 From: Brian LaFond <52360893+iroxusux@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:05:23 -0400 Subject: [PATCH 1/2] update for cli services --- README.md | 2 +- pyproject.toml | 2 +- pyrox/__init__.py | 2 + pyrox/core/formatters.py | 5 + pyrox/interfaces/cli/__init__.py | 0 pyrox/interfaces/cli/state.py | 29 +++++ pyrox/services/cli/__init__.py | 57 +++++++++ pyrox/services/cli/alt/__init__.py | 0 pyrox/services/cli/alt/core.py | 0 pyrox/services/cli/alt/line.py | 45 +++++++ pyrox/services/cli/alt/menu.py | 47 ++++++++ pyrox/services/cli/alt/mode.py | 24 ++++ pyrox/services/cli/alt/print.py | 17 +++ pyrox/services/cli/app.py | 89 ++++++++++++++ pyrox/services/cli/core.py | 115 ++++++++++++++++++ pyrox/services/cli/menu.py | 54 +++++++++ pyrox/services/cli/print.py | 121 +++++++++++++++++++ pyrox/services/cli/state.py | 187 +++++++++++++++++++++++++++++ pyrox/services/cli/tfixture.py | 29 +++++ test/services/__init__.py | 0 test/services/cli/__init__.py | 0 test/services/cli/alt/__init__.py | 0 test/services/cli/test_core.py | 44 +++++++ 23 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 pyrox/core/formatters.py create mode 100644 pyrox/interfaces/cli/__init__.py create mode 100644 pyrox/interfaces/cli/state.py create mode 100644 pyrox/services/cli/__init__.py create mode 100644 pyrox/services/cli/alt/__init__.py create mode 100644 pyrox/services/cli/alt/core.py create mode 100644 pyrox/services/cli/alt/line.py create mode 100644 pyrox/services/cli/alt/menu.py create mode 100644 pyrox/services/cli/alt/mode.py create mode 100644 pyrox/services/cli/alt/print.py create mode 100644 pyrox/services/cli/app.py create mode 100644 pyrox/services/cli/core.py create mode 100644 pyrox/services/cli/menu.py create mode 100644 pyrox/services/cli/print.py create mode 100644 pyrox/services/cli/state.py create mode 100644 pyrox/services/cli/tfixture.py create mode 100644 test/services/__init__.py create mode 100644 test/services/cli/__init__.py create mode 100644 test/services/cli/alt/__init__.py create mode 100644 test/services/cli/test_core.py diff --git a/README.md b/README.md index cd45274..cf82d14 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Python Version](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/license-GPL--3.0-green.svg)](LICENSE) ![Development Status](https://img.shields.io/badge/status-beta-orange.svg) -![Version](https://img.shields.io/badge/version-3.6.11-blue.svg) +![Version](https://img.shields.io/badge/version-3.6.12-blue.svg) **Pyrox** is a Python back-end engine and application framework built on **PyQt6**. It provides a rich set of interfaces, models, services, and abstractions for building industrial automation and desktop applications. Pyrox is designed to be used as a foundation — downstream projects like [ControlRox](https://github.com/iroxusux/ControlRox) build their entire application layer on top of it. diff --git a/pyproject.toml b/pyproject.toml index ea5770b..9a3c79e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyrox" -version = "3.6.11" +version = "3.6.12" authors = [{ name = "Brian LaFond", email = "Brian.L.LaFond@gmail.com" }] description = "Python based irox engine." readme = "README.md" diff --git a/pyrox/__init__.py b/pyrox/__init__.py index 698336e..67bc091 100644 --- a/pyrox/__init__.py +++ b/pyrox/__init__.py @@ -4,6 +4,7 @@ """ from . import ( + core, interfaces, services, models, @@ -15,6 +16,7 @@ __all__ = ( + 'core', 'interfaces', 'services', 'models', diff --git a/pyrox/core/formatters.py b/pyrox/core/formatters.py new file mode 100644 index 0000000..7f052bc --- /dev/null +++ b/pyrox/core/formatters.py @@ -0,0 +1,5 @@ +"""General formatters for strings within the pyrox environment. +""" + + + diff --git a/pyrox/interfaces/cli/__init__.py b/pyrox/interfaces/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyrox/interfaces/cli/state.py b/pyrox/interfaces/cli/state.py new file mode 100644 index 0000000..664b14f --- /dev/null +++ b/pyrox/interfaces/cli/state.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod + + +class AppState(ABC): + """Abstract base class representing a single terminal screen or mode.""" + + def __init__(self, app_context): + self.is_alive = False # Alive context for rendering and processing + self.app = app_context # Reference to the main application orchestrator + + @abstractmethod + def enter(self) -> None: + """Called when entering this state (e.g., clear screen, set alt mode).""" + + @abstractmethod + def handle_input(self, key: str) -> None: + """Processes keyboard inputs specific to this screen state.""" + + @abstractmethod + def render(self) -> None: + """Draws the UI components for this state onto the terminal.""" + + @abstractmethod + def exit(self) -> None: + """Called before transitioning away to clean up local state.""" + + @abstractmethod + def mark_dirty(self) -> None: + """Mark all lines for this state as 'dirty' (needs re-rendering.)""" diff --git a/pyrox/services/cli/__init__.py b/pyrox/services/cli/__init__.py new file mode 100644 index 0000000..5996743 --- /dev/null +++ b/pyrox/services/cli/__init__.py @@ -0,0 +1,57 @@ +# ┌────────────────────────┐ +# │ TerminalApplication │ ◄───(Main Orchestrator) +# └───────────┬────────────┘ +# │ +# Tracks Current State +# │ +# ▼ +# ┌────────────────────────┐ +# │ AppState (Base) │ +# └─────┬────────────┬─────┘ +# │ │ +# Inherits │ │ Inherits +# ▼ ▼ +# ┌─────────────────┐ ┌──────────────────┐ +# │ MainMenuState │ │ AltEditorState │ ... (Other Screens) +# └─────────────────┘ └──────────────────┘ +import sys +import time + + +def update_console_lines(line_count: int, line_content: list[str]) -> None: + """Update console lines by overwriting existing values and rewriting over the buffer. + This method prevents flicker on the console for 'clear' events. + + Args: + line_count (int): Number of lines to overwrite + line_content (list[str]): List of lines to newly fill the console with + """ + for _ in range(len(line_content)): + sys.stdout.write(f'\033[{line_count}A') + + for line in line_content: + sys.stdout.write(f"\033[K{line}\n") + + sys.stdout.flush() + + +if __name__ == '__main__': + try: + while True: + update_console_lines( + line_count=10, + line_content=[ + 'Here is line 1', + 'Here is line 2', + 'Here is line 3', + 'Here is line 4', + 'Here is line 5', + 'Here is line 6', + 'Here is line 7', + 'Here is line 8', + 'Here is line 9', + f'Current time is {time.time().hex}' + ] + ) + except KeyboardInterrupt: + pass diff --git a/pyrox/services/cli/alt/__init__.py b/pyrox/services/cli/alt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyrox/services/cli/alt/core.py b/pyrox/services/cli/alt/core.py new file mode 100644 index 0000000..e69de29 diff --git a/pyrox/services/cli/alt/line.py b/pyrox/services/cli/alt/line.py new file mode 100644 index 0000000..32932d3 --- /dev/null +++ b/pyrox/services/cli/alt/line.py @@ -0,0 +1,45 @@ + +class TrackedLine: + """Tracked line for the CLI / Terminal Interface. + Designed for alternate mode use. + """ + + def __repr__(self) -> str: + return self.value + + def __init__( + self, + line_no: int, + value: str + ) -> None: + if line_no < 1: + raise ValueError(f'Line number must be positive! Got {line_no}') + self._line_no = line_no + self._value = value + self._dirty = True + + @property + def dirty(self) -> bool: + return self._dirty + + @property + def line_no(self) -> int: + return self._line_no + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, value: str) -> None: + if self._value != value: + self._value = value + self._dirty = True + + def clean(self) -> None: + """Unmark this line from being dirty.""" + self._dirty = False + + def mark_dirty(self) -> None: + """Mark this line as dirty / needs re-rendering.""" + self._dirty = True diff --git a/pyrox/services/cli/alt/menu.py b/pyrox/services/cli/alt/menu.py new file mode 100644 index 0000000..39f5ed6 --- /dev/null +++ b/pyrox/services/cli/alt/menu.py @@ -0,0 +1,47 @@ +import msvcrt +from typing import Callable + +from pyrox.core.validators import unsafe_assert_is_type +from pyrox.services.cli.core import clear, clear_input_buffer, ANSIFormatter +from pyrox.services.cli.print import ( + update_console_lines +) + + +def alternate_menu(items: list[tuple[str, Callable]]) -> None: + clear() + pointer = 0 + length = len(items) + mut_lines = [''] * length + + while True: + for x in range(length): + unsafe_assert_is_type(items[x][0], str) + if not callable(items[x][1]): + raise ValueError('Second item in tuple MUST be callable!') + mut_lines[x] = f"[{x}] {items[x][0]}" + if pointer == x: + mut_lines[x] += ' <' + + update_console_lines(length, mut_lines) + char = msvcrt.getch().decode('utf-8', errors='ignore') + if char.lower() == 'j': + pointer += 1 + if char.lower() == 'k': + pointer -= 1 + if pointer >= length: + pointer = 0 + if pointer < 0: + pointer = length - 1 + if char in ANSIFormatter.ENTER_CHARS: + items[pointer][1]() + return + clear_input_buffer() + + +if __name__ == '__main__': + alternate_menu([ + ('item1', lambda: print('item 1 selected')), + ('item2', lambda: print('item 2 selected')), + ('item3', lambda: print('item 3 selected')), + ]) diff --git a/pyrox/services/cli/alt/mode.py b/pyrox/services/cli/alt/mode.py new file mode 100644 index 0000000..aeec86f --- /dev/null +++ b/pyrox/services/cli/alt/mode.py @@ -0,0 +1,24 @@ +import sys + + +REG_BUFFER_CMD = '\x1b[?1049l' +ALT_BUFFER_CMD = '\x1b[?1049h' + + +def enter_alternate_mode(): + sys.stdout.write(ALT_BUFFER_CMD) + sys.stdout.flush() + + +def exit_alternate_mode(): + sys.stdout.write(REG_BUFFER_CMD) + + +if __name__ == '__main__': + try: + enter_alternate_mode() + while True: + word = input('Enter something, idiot') + print(word) + finally: + exit_alternate_mode() diff --git a/pyrox/services/cli/alt/print.py b/pyrox/services/cli/alt/print.py new file mode 100644 index 0000000..1d03981 --- /dev/null +++ b/pyrox/services/cli/alt/print.py @@ -0,0 +1,17 @@ +from pyrox.services.cli.alt.line import TrackedLine +from pyrox.services.cli.core import ANSIFormatter + + +def update_target_line(line: TrackedLine) -> None: + """Update a targeted line in the console / terminal. + Designed for alternate mode use. + + Args: + line (TrackedLine): :class:`TrackedLine` to update its' value. + """ + if not line.dirty: + return + ANSIFormatter.move_absolute(line.line_no, 1, end='') + ANSIFormatter.clear_to_end_of_line(end='') + print(line.value, end='', flush=True) + line.clean() diff --git a/pyrox/services/cli/app.py b/pyrox/services/cli/app.py new file mode 100644 index 0000000..42df905 --- /dev/null +++ b/pyrox/services/cli/app.py @@ -0,0 +1,89 @@ +import msvcrt +from pyrox.interfaces.cli.state import AppState + + +class TerminalApplication: + """The central manager that orchestrates states and terminal lifecycles.""" + + def __init__( + self, + initial_state: AppState | None = None + ): + self.state_tracking: list[AppState] = [] + self._fallback_state = initial_state + if self._fallback_state: + self._fallback_state.app = self + self.current_state = None + self.is_running = False + + def __enter__(self): + """Context manager setup. Prepares terminal settings if needed.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Guarantees terminal restoration even if the app crashes.""" + # Force exit the current state to trigger its clean-up (like disabling alt mode) + if self.current_state: + self.current_state.exit() + + # ANSI fallback to ensure main screen and cursor are restored + print("\033[?1049l\033[?25h", end="", flush=True) + print("\nTerminal connection closed cleanly.") + + def change_state(self, new_state: AppState) -> None: + """Safely transitions from one console state to another.""" + if self.current_state: + self.current_state.exit() + self.state_tracking.append(new_state) + self.state_tracking[-1].app = self + self.current_state = self.state_tracking[-1] + self.current_state.enter() + + def restore_state(self) -> None: + """Transion from current state to last known state. + If no previous state found, safely transtion to MainMenuState + """ + if self.current_state: + self.current_state.exit() + self.state_tracking.pop() + + try: + prev_state = self.state_tracking[-1] + except IndexError: + prev_state = None + + if not prev_state: + if self._fallback_state: + prev_state = self._fallback_state + else: + raise ValueError('No initial state to fall back to!') + self.state_tracking = [prev_state] + + self.current_state = self.state_tracking[-1] + self.current_state.enter() + + def stop(self) -> None: + self.is_running = False + + def run(self, state: AppState | None = None) -> None: + """Launches the main execution loop.""" + self.is_running = True + if state is None and self._fallback_state: + state = self._fallback_state + if state is not None and not self._fallback_state: + self._fallback_state = state + if not state: + raise RuntimeError('Cannot run this application without a known, valid state!') + self.change_state(state) + + if not self.current_state: + raise RuntimeError('Error changing state!') + + # Basic placeholder execution loop + while self.is_running: + try: + # In a real app, use a non-blocking key reader (like `readchar` or `curses`) + char = msvcrt.getch().decode('utf-8', errors='ignore') + self.current_state.handle_input(char) + except (KeyboardInterrupt, SystemExit): + self.stop() diff --git a/pyrox/services/cli/core.py b/pyrox/services/cli/core.py new file mode 100644 index 0000000..3386d60 --- /dev/null +++ b/pyrox/services/cli/core.py @@ -0,0 +1,115 @@ +"""Core components and methods for CLI / Terminal interaction + """ +import msvcrt +import os + + +class ANSIFormatter: + """Provides parametrized ANSI escape sequences for CLI formatting.""" + REG_BUFFER_CMD = '\x1b[?1049l' + ALT_BUFFER_CMD = '\x1b[?1049h' + + ESC = "\033[" + ENTER_CHARS = [' ', '\r'] + + def __init__(self) -> None: + raise TypeError("This class is static and cannot be created!") + + @classmethod + def enter_regular_mode(cls, end: str = '\n', flush: bool = False) -> None: + print(cls.REG_BUFFER_CMD, end=end, flush=flush) + + @classmethod + def enter_alt_mode(cls) -> None: + print(cls.ALT_BUFFER_CMD) + + @classmethod + def move_home(cls, end: str = "\n", flush: bool = False) -> None: + """Moves the cursor home.""" + print(f'{cls.ESC}H', end=end, flush=flush) + + @classmethod + def move_absolute(cls, row: int, col: int, end: str = "\n", flush: bool = False) -> None: + """Moves the cursor to a specific row and column.""" + print(f'{cls.ESC}{row};{col}H', end=end, flush=flush) + + @classmethod + def move_to_column(cls, col: int) -> None: + """Moves the cursor to a specific column.""" + print(f'{cls.ESC}{col}G') + + @classmethod + def cursor_up(cls, n: int = 1) -> None: + """Moves the cursor up by n lines.""" + print(f"{cls.ESC}{n}A") + + @classmethod + def cursor_down(cls, n: int = 1) -> None: + """Moves the cursor down by n lines.""" + print(f"{cls.ESC}{n}B") + + @classmethod + def cursor_right(cls, n: int = 1) -> None: + """Moves the cursor right by n columns.""" + print(f"{cls.ESC}{n}C") + + @classmethod + def cursor_left(cls, n: int = 1) -> None: + """Moves the cursor left by n columns.""" + print(f"{cls.ESC}{n}D") + + @classmethod + def hide_cursor(cls) -> None: + """Hide the cursor from the cuser.""" + print(f'{cls.ESC}?25l') + + @classmethod + def show_cursor(cls) -> None: + """Show the cursor to the user.""" + print(f'{cls.ESC}?25h') + + @classmethod + def save_cursor_position(cls) -> None: + """Save cursor position.""" + print(f'{cls.ESC}s') + + @classmethod + def restor_last_cursor_position(cls) -> None: + """Restore the last cursor position saved.""" + print(f'{cls.ESC}u') + + @classmethod + def text_color_256(cls, n: int) -> None: + """Sets foreground color using 256-color palette (0-255).""" + print(f"{cls.ESC}38;5;{n}m") + + @classmethod + def bg_color_256(cls, n: int) -> None: + """Sets background color using 256-color palette (0-255).""" + print(f"{cls.ESC}48;5;{n}m") + + @classmethod + def clear_screen(cls) -> None: + """Clear the screen, leaving cursor in place.""" + print(f'{cls.ESC}2J') + + @classmethod + def clear_to_end_of_line(cls, end: str = '\n', flush: bool = False) -> None: + """Clear from the cursor to the end of the current line.""" + print(f'{cls.ESC}K', end=end, flush=flush) + + @classmethod + def clear_line(cls) -> None: + """Clear the entire current line.""" + print(f'{cls.ESC}2K') + + +def clear(): + os.system('cls' if os.name == 'nt' else 'clear') + clear_input_buffer() + + +def clear_input_buffer(): + # Clear any pending characters sitting in the buffer + while msvcrt.kbhit(): + msvcrt.getch() diff --git a/pyrox/services/cli/menu.py b/pyrox/services/cli/menu.py new file mode 100644 index 0000000..abac7e1 --- /dev/null +++ b/pyrox/services/cli/menu.py @@ -0,0 +1,54 @@ +from typing import Callable +from pyrox.core.validators import unsafe_assert_is_type +from pyrox.services.cli.core import clear +from pyrox.services.cli.print import ( + print_header, + print_user_input_prompt, +) + + +class MenuItem: + def __init__( + self, + display_value: str, + callback: Callable + ) -> None: + self.display_value = display_value + self.callback = callback + + +def interactive_menu(items: list[tuple[str, Callable]]) -> None: + """Handle list of callables by displaying them to a user and calling the option they select. + + Args: + items (list[tuple[str, Callable]]): List of string (display) and call-back (function). + + Raises: + ValueError: If tuple index 0 is not a string or tuple index 1 is not a callable function. + """ + clear() + + if not items: + return + + print_header('Select an item below...') + + for index, item in enumerate(items): + unsafe_assert_is_type(item[0], str) + if not callable(item[1]): + raise ValueError('Second item in tuple MUST be callable!') + print(f"[{index}] {item[0]}") + + user_selection = print_user_input_prompt() + if not user_selection: + interactive_menu(items) + try: + index = int(user_selection) + items[index][1]() + return + except IndexError: + input(f'Invalid selection: {user_selection}') + interactive_menu(items) + except ValueError: + input(f'Selection must be a valid number between 0 -> {len(items)}... Got {user_selection}...') + interactive_menu(items) diff --git a/pyrox/services/cli/print.py b/pyrox/services/cli/print.py new file mode 100644 index 0000000..b3db4e8 --- /dev/null +++ b/pyrox/services/cli/print.py @@ -0,0 +1,121 @@ +"""Print utilities for command line interface +""" +import sys + + +# --- Line generator methods ---------- +def _make_pretty(message: str, header_char: str, fill_char: str) -> str: + return f'{header_char} {fill_char * 3} {message} {fill_char * 10} '.ljust(25, fill_char) + + +def _continue_prompt_str() -> str: + return 'Press "Enter" to continue...' + + +def _user_input_prompt_str() -> str: + return '>>> ' + + +# --- Print methods ---------- +def print_user_input_prompt() -> str: + """General helper method to prompt a user for input feedback. + For consistency across the codebase. + + Returns: + str: The user's response. + """ + return input(_user_input_prompt_str()) + + +def print_continue_prompt() -> str: + """General helper method to prompt the user to press 'Enter' to continue. + For consistency across the codebase. + + Returns: + str: The user's response. + """ + return input(_continue_prompt_str()) + + +def prompt_user_confirm(confirm_message: str) -> bool: + keys = ['y', 'yes'] + print(f'{confirm_message} -> [{keys}]') + return print_user_input_prompt() in keys + + +def print_header(message: str, header_char: str = '#') -> None: + """Print a header line. + This method relies on :method:`print_pretty_line`, passing default characters to work. + + Args: + message (str): Message to print to terminal. + header_char (str, optional): Character to begin header with. Defaults to '#'. + + Example Output: + >>> + # --- This is an example header! ---------- + """ + print_pretty_line(message, header_char) + + +def print_pretty_line(message: str, header_char: str = '#', fill_char: str = ' ') -> None: + """Print a pretty line to the console / terminal. + + Args: + message (str): Message to print. + header_char (str, optional): Header character to begin message with. Defaults to '#'. + fill_char (str, optional): Fill character to wrap message with. Defaults to ' '. + + Raises: + ValueError: If header_char is not a length of 1 (single character). + ValueError: If fill_char is not a length of 1 (single character). + + Example Output: + >>> + print_pretty_line('This is an example message!, fill_char='-') + # --- This is an example pretty message! ---------- + """ + if len(header_char) != 1: + raise ValueError('Header character must be a single character!') + if len(fill_char) != 1: + raise ValueError('Fill character must be a single character!') + print(_make_pretty(message, header_char, fill_char)) + + +# --- Buffer methods ---------- +def buffer_user_input_prompt(buffer: list[str]) -> None: + buffer.append(_user_input_prompt_str()) + + +def buffer_header(buffer: list[str], message: str) -> None: + buffer_pretty_line(buffer, message, '-') + + +def buffer_line(buffer: list[str], message: str = '') -> None: + buffer.append(message) + + +def buffer_pretty_line(buffer: list[str], message: str, header_char: str = '#', fill_char: str = ' ') -> None: + if len(header_char) != 1: + raise ValueError('Header character must be a single character!') + if len(fill_char) != 1: + raise ValueError('Fill character must be a single character!') + buffer.append(_make_pretty(message, '#', fill_char)) + + +# --- Update methods ---------- +def update_console_lines(line_count: int, line_content: list[str]) -> None: + """Update console lines by overwriting existing values and rewriting over the buffer. + This method prevents flicker on the console for 'clear' events. + + Args: + line_count (int): Number of lines to overwrite + line_content (list[str]): List of lines to newly fill the console with + """ + for _ in range(len(line_content)): + sys.stdout.write(f'\033[{line_count}A') + + for line in line_content: + sys.stdout.write(f"\033[K{line}\n") + + sys.stdout.flush() diff --git a/pyrox/services/cli/state.py b/pyrox/services/cli/state.py new file mode 100644 index 0000000..32b4778 --- /dev/null +++ b/pyrox/services/cli/state.py @@ -0,0 +1,187 @@ +from pyrox.interfaces.cli.state import AppState +from pyrox.services.cli.core import ANSIFormatter +from pyrox.services.cli.print import _make_pretty +from pyrox.services.cli.menu import MenuItem +from pyrox.services.cli.alt.line import TrackedLine +from pyrox.services.cli.alt.print import update_target_line + + +class _BaseState(AppState): + def enter(self): + self.is_alive = True + self.render() + + def exit(self) -> None: + self.is_alive = False + + +class MainMenuState(_BaseState): + def handle_input(self, key): + if key == "e": + # Transition to the editor (which uses Alt Mode) + self.app.change_state(AltEditorState(self.app)) + elif key == "q": + self.app.stop() + + def render(self): + print("\n=== MAIN MENU ===") + print("[e] Open Advanced Editor (Alt Mode)") + print("[q] Quit Application") + + +class AltEditorState(_BaseState): + def enter(self): + # Enable Alternate Screen Buffer using ANSI escape codes + print("\033[?1049h\033[H", end="", flush=True) + self.render() + + def handle_input(self, key): + if key == "b": + # Go back to main menu + self.app.change_state(MainMenuState(self.app)) + + def render(self): + # Clear screen and draw editor UI + print("\033[2J\033[H", end="") + print("--- ADVANCED ALT-MODE EDITOR ---") + print("Type text here... (Simulated)") + print("\nPress [b] to return to Main Menu.") + + def exit(self): + # Disable Alternate Screen Buffer safely when leaving + print("\033[?1049l", end="", flush=True) + + +class InteractiveMenuState(_BaseState): + + def __init__( + self, + app_context, + title: str, + items: list[MenuItem], + footer: str = '', + pointer_char: str = '<', + as_root: bool = False + ): + super().__init__(app_context) + self.title = title + self.items = items + self.length = len(items) + self.pointer = 0 + line_items = [] + for x in range(self.length): + text = _make_pretty(self.items[x].display_value, '#', ' ') + line_items.append(TrackedLine(2+x, text)) + + self.lines = { + 'header': TrackedLine(1, _make_pretty(title, '#', '-')), + 'list': line_items, + 'status': TrackedLine(3 + self.length, ''), + 'footer': TrackedLine(4 + self.length, footer), + } + self._can_exit = not as_root + if self._can_exit: + self.lines['end'] = TrackedLine(5 + self.length, 'Press "e" to exit...') + + if len(pointer_char) != 1: + raise ValueError('Pointer character must be a single character string!') + self.pointer_char = pointer_char + + @property + def status(self) -> TrackedLine: + return self.lines['status'] + + def all_lines(self) -> list[TrackedLine]: + lines = [] + for value in self.lines.values(): + if isinstance(value, list): + lines.extend(value) + elif isinstance(value, TrackedLine): + lines.append(value) + else: + raise ValueError(f'Unexpected type found! {type(value)}') + return lines + + # --- Pointer Manipulation ---------- + def _strip_pointer(self, line: TrackedLine) -> None: + """Remove the pointer character from a given line. """ + if not line.value.strip().endswith(self.pointer_char): + return + line.value = line.value.removesuffix(self.pointer_char) + + def _append_pointer(self, line: TrackedLine) -> None: + """Append pointer character to a given line.""" + if line.value.strip().endswith(self.pointer_char): + return + line.value = line.value + f'{self.pointer_char}' + + def _change_line_position(self, offset: int) -> None: + self._strip_pointer(self.lines['list'][self.pointer]) + self.pointer += offset + if self.pointer >= self.length: + self.pointer = 0 + if self.pointer < 0: + self.pointer = self.length - 1 + self._append_pointer(self.lines['list'][self.pointer]) + + def _increment_pointer(self): + self._change_line_position(1) + + def _decrement_pointer(self): + self._change_line_position(-1) + + def _set_pointer(self, pos: int) -> None: + """Set pointer to a specified value.""" + self._change_line_position(-(self.pointer - pos)) + + # --- Execute ---------- + def execute_line_item(self): + ret_value = self.items[self.pointer].callback() + if ret_value is not None: + self.status.value = f'Got value from callback: {ret_value}' + + # --- Abstract Fullfillment ---------- + def enter(self): + super().enter() + # Enable Alternate Screen Buffer using ANSI escape codes + ANSIFormatter.enter_alt_mode() + ANSIFormatter.clear_screen() + ANSIFormatter.move_home(end="", flush=True) + ANSIFormatter.hide_cursor() + self._set_pointer(0) + self.render(redraw=True) + + def handle_input(self, key): + if key == 'e': + if not self._can_exit: + return + self.app.restore_state() + if key == 'j': + self._increment_pointer() + if key == 'k': + self._decrement_pointer() + if key in ANSIFormatter.ENTER_CHARS: + self.execute_line_item() + self.render() + + def render(self, redraw: bool = False): + # Don't process if our app killed us. + if not self.is_alive: + return + # Clear screen and draw editor UI + for line in self.all_lines(): + if redraw: + line.mark_dirty() + update_target_line(line) + + def exit(self): + # Disable Alternate Screen Buffer safely when leaving + ANSIFormatter.enter_regular_mode(end="", flush=True) + ANSIFormatter.clear_screen() + ANSIFormatter.move_home(end="", flush=True) + ANSIFormatter.show_cursor() + super().exit() + + def mark_dirty(self) -> None: + for line in self.all_lines(): + line.mark_dirty() diff --git a/pyrox/services/cli/tfixture.py b/pyrox/services/cli/tfixture.py new file mode 100644 index 0000000..d531792 --- /dev/null +++ b/pyrox/services/cli/tfixture.py @@ -0,0 +1,29 @@ +"""Test fixture file so i dont have to keep changing envs while developing this in my core. +""" +from pyrox.services.cli.app import TerminalApplication +from pyrox.services.cli.menu import MenuItem +from pyrox.services.cli.state import InteractiveMenuState + + +if __name__ == '__main__': + sub_state = InteractiveMenuState( + None, + 'Sub Menu', + [ + MenuItem('Child item 1', lambda: 'child item 1 selected'), + MenuItem('Child item 2', lambda: 'child item 2 selected'), + MenuItem('Child item 3', lambda: 'child item 3 selected'), + ] + ) + main_state = InteractiveMenuState( + None, + 'Interactive Menu Demo', + [ + MenuItem('Go to sub-menu', lambda: app.change_state(sub_state)), + MenuItem('Item 2', lambda: 'item 2 selected'), + MenuItem('Item 3', lambda: 'item 3 selected'), + MenuItem('Exit', lambda: exit(0)) + ] + ) + with TerminalApplication(initial_state=main_state) as app: + app.run() diff --git a/test/services/__init__.py b/test/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/services/cli/__init__.py b/test/services/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/services/cli/alt/__init__.py b/test/services/cli/alt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/services/cli/test_core.py b/test/services/cli/test_core.py new file mode 100644 index 0000000..7941bbd --- /dev/null +++ b/test/services/cli/test_core.py @@ -0,0 +1,44 @@ +import pytest + +from pyrox.services.cli.core import ANSIFormatter + + +class TestAnsiFormatter: + + def test_cannot_init(self): + with pytest.raises(TypeError): + ANSIFormatter() + + def test_move_home(self): + assert ANSIFormatter.move_home() + + def test_esc_seq(self): + assert ANSIFormatter.ESC == '\033[' + + def test_enter_pressed_chars(self): + assert ' ' in ANSIFormatter.ENTER_CHARS + assert '\r' in ANSIFormatter.ENTER_CHARS + + def test_cursor_up(self): + for x in range(256): + assert ANSIFormatter.cursor_up(x) == f'\033[{x}A' + + def test_cursor_down(self): + for x in range(256): + assert ANSIFormatter.cursor_down(x) == f'\033[{x}B' + + def test_cursor_right(self): + for x in range(256): + assert ANSIFormatter.cursor_right(x) == f'\033[{x}C' + + def test_cursor_left(self): + for x in range(256): + assert ANSIFormatter.cursor_left(x) == f'\033[{x}D' + + def test_text_color(self): + for x in range(256): + assert ANSIFormatter.text_color_256(x) == f"\033[38;5;{x}m" + + def test_bg_color(self): + for x in range(256): + assert ANSIFormatter.bg_color_256(x) == f"\033[48;5;{x}m" From 40ca65dc043a4b9048ac55a1017415695d39317e Mon Sep 17 00:00:00 2001 From: Brian LaFond <52360893+iroxusux@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:41:39 -0400 Subject: [PATCH 2/2] remove cli components to move to cliRox repo --- README.md | 2 +- pyproject.toml | 2 +- pyrox/interfaces/cli/__init__.py | 0 pyrox/interfaces/cli/state.py | 29 ----- pyrox/services/cli/__init__.py | 57 --------- pyrox/services/cli/alt/__init__.py | 0 pyrox/services/cli/alt/core.py | 0 pyrox/services/cli/alt/line.py | 45 ------- pyrox/services/cli/alt/menu.py | 47 -------- pyrox/services/cli/alt/mode.py | 24 ---- pyrox/services/cli/alt/print.py | 17 --- pyrox/services/cli/app.py | 89 -------------- pyrox/services/cli/core.py | 115 ------------------ pyrox/services/cli/menu.py | 54 --------- pyrox/services/cli/print.py | 121 ------------------- pyrox/services/cli/state.py | 187 ----------------------------- pyrox/services/cli/tfixture.py | 29 ----- 17 files changed, 2 insertions(+), 816 deletions(-) delete mode 100644 pyrox/interfaces/cli/__init__.py delete mode 100644 pyrox/interfaces/cli/state.py delete mode 100644 pyrox/services/cli/__init__.py delete mode 100644 pyrox/services/cli/alt/__init__.py delete mode 100644 pyrox/services/cli/alt/core.py delete mode 100644 pyrox/services/cli/alt/line.py delete mode 100644 pyrox/services/cli/alt/menu.py delete mode 100644 pyrox/services/cli/alt/mode.py delete mode 100644 pyrox/services/cli/alt/print.py delete mode 100644 pyrox/services/cli/app.py delete mode 100644 pyrox/services/cli/core.py delete mode 100644 pyrox/services/cli/menu.py delete mode 100644 pyrox/services/cli/print.py delete mode 100644 pyrox/services/cli/state.py delete mode 100644 pyrox/services/cli/tfixture.py diff --git a/README.md b/README.md index cf82d14..5cd247a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Python Version](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/license-GPL--3.0-green.svg)](LICENSE) ![Development Status](https://img.shields.io/badge/status-beta-orange.svg) -![Version](https://img.shields.io/badge/version-3.6.12-blue.svg) +![Version](https://img.shields.io/badge/version-3.6.13-blue.svg) **Pyrox** is a Python back-end engine and application framework built on **PyQt6**. It provides a rich set of interfaces, models, services, and abstractions for building industrial automation and desktop applications. Pyrox is designed to be used as a foundation — downstream projects like [ControlRox](https://github.com/iroxusux/ControlRox) build their entire application layer on top of it. diff --git a/pyproject.toml b/pyproject.toml index 9a3c79e..366d457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyrox" -version = "3.6.12" +version = "3.6.13" authors = [{ name = "Brian LaFond", email = "Brian.L.LaFond@gmail.com" }] description = "Python based irox engine." readme = "README.md" diff --git a/pyrox/interfaces/cli/__init__.py b/pyrox/interfaces/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyrox/interfaces/cli/state.py b/pyrox/interfaces/cli/state.py deleted file mode 100644 index 664b14f..0000000 --- a/pyrox/interfaces/cli/state.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod - - -class AppState(ABC): - """Abstract base class representing a single terminal screen or mode.""" - - def __init__(self, app_context): - self.is_alive = False # Alive context for rendering and processing - self.app = app_context # Reference to the main application orchestrator - - @abstractmethod - def enter(self) -> None: - """Called when entering this state (e.g., clear screen, set alt mode).""" - - @abstractmethod - def handle_input(self, key: str) -> None: - """Processes keyboard inputs specific to this screen state.""" - - @abstractmethod - def render(self) -> None: - """Draws the UI components for this state onto the terminal.""" - - @abstractmethod - def exit(self) -> None: - """Called before transitioning away to clean up local state.""" - - @abstractmethod - def mark_dirty(self) -> None: - """Mark all lines for this state as 'dirty' (needs re-rendering.)""" diff --git a/pyrox/services/cli/__init__.py b/pyrox/services/cli/__init__.py deleted file mode 100644 index 5996743..0000000 --- a/pyrox/services/cli/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -# ┌────────────────────────┐ -# │ TerminalApplication │ ◄───(Main Orchestrator) -# └───────────┬────────────┘ -# │ -# Tracks Current State -# │ -# ▼ -# ┌────────────────────────┐ -# │ AppState (Base) │ -# └─────┬────────────┬─────┘ -# │ │ -# Inherits │ │ Inherits -# ▼ ▼ -# ┌─────────────────┐ ┌──────────────────┐ -# │ MainMenuState │ │ AltEditorState │ ... (Other Screens) -# └─────────────────┘ └──────────────────┘ -import sys -import time - - -def update_console_lines(line_count: int, line_content: list[str]) -> None: - """Update console lines by overwriting existing values and rewriting over the buffer. - This method prevents flicker on the console for 'clear' events. - - Args: - line_count (int): Number of lines to overwrite - line_content (list[str]): List of lines to newly fill the console with - """ - for _ in range(len(line_content)): - sys.stdout.write(f'\033[{line_count}A') - - for line in line_content: - sys.stdout.write(f"\033[K{line}\n") - - sys.stdout.flush() - - -if __name__ == '__main__': - try: - while True: - update_console_lines( - line_count=10, - line_content=[ - 'Here is line 1', - 'Here is line 2', - 'Here is line 3', - 'Here is line 4', - 'Here is line 5', - 'Here is line 6', - 'Here is line 7', - 'Here is line 8', - 'Here is line 9', - f'Current time is {time.time().hex}' - ] - ) - except KeyboardInterrupt: - pass diff --git a/pyrox/services/cli/alt/__init__.py b/pyrox/services/cli/alt/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyrox/services/cli/alt/core.py b/pyrox/services/cli/alt/core.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyrox/services/cli/alt/line.py b/pyrox/services/cli/alt/line.py deleted file mode 100644 index 32932d3..0000000 --- a/pyrox/services/cli/alt/line.py +++ /dev/null @@ -1,45 +0,0 @@ - -class TrackedLine: - """Tracked line for the CLI / Terminal Interface. - Designed for alternate mode use. - """ - - def __repr__(self) -> str: - return self.value - - def __init__( - self, - line_no: int, - value: str - ) -> None: - if line_no < 1: - raise ValueError(f'Line number must be positive! Got {line_no}') - self._line_no = line_no - self._value = value - self._dirty = True - - @property - def dirty(self) -> bool: - return self._dirty - - @property - def line_no(self) -> int: - return self._line_no - - @property - def value(self) -> str: - return self._value - - @value.setter - def value(self, value: str) -> None: - if self._value != value: - self._value = value - self._dirty = True - - def clean(self) -> None: - """Unmark this line from being dirty.""" - self._dirty = False - - def mark_dirty(self) -> None: - """Mark this line as dirty / needs re-rendering.""" - self._dirty = True diff --git a/pyrox/services/cli/alt/menu.py b/pyrox/services/cli/alt/menu.py deleted file mode 100644 index 39f5ed6..0000000 --- a/pyrox/services/cli/alt/menu.py +++ /dev/null @@ -1,47 +0,0 @@ -import msvcrt -from typing import Callable - -from pyrox.core.validators import unsafe_assert_is_type -from pyrox.services.cli.core import clear, clear_input_buffer, ANSIFormatter -from pyrox.services.cli.print import ( - update_console_lines -) - - -def alternate_menu(items: list[tuple[str, Callable]]) -> None: - clear() - pointer = 0 - length = len(items) - mut_lines = [''] * length - - while True: - for x in range(length): - unsafe_assert_is_type(items[x][0], str) - if not callable(items[x][1]): - raise ValueError('Second item in tuple MUST be callable!') - mut_lines[x] = f"[{x}] {items[x][0]}" - if pointer == x: - mut_lines[x] += ' <' - - update_console_lines(length, mut_lines) - char = msvcrt.getch().decode('utf-8', errors='ignore') - if char.lower() == 'j': - pointer += 1 - if char.lower() == 'k': - pointer -= 1 - if pointer >= length: - pointer = 0 - if pointer < 0: - pointer = length - 1 - if char in ANSIFormatter.ENTER_CHARS: - items[pointer][1]() - return - clear_input_buffer() - - -if __name__ == '__main__': - alternate_menu([ - ('item1', lambda: print('item 1 selected')), - ('item2', lambda: print('item 2 selected')), - ('item3', lambda: print('item 3 selected')), - ]) diff --git a/pyrox/services/cli/alt/mode.py b/pyrox/services/cli/alt/mode.py deleted file mode 100644 index aeec86f..0000000 --- a/pyrox/services/cli/alt/mode.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys - - -REG_BUFFER_CMD = '\x1b[?1049l' -ALT_BUFFER_CMD = '\x1b[?1049h' - - -def enter_alternate_mode(): - sys.stdout.write(ALT_BUFFER_CMD) - sys.stdout.flush() - - -def exit_alternate_mode(): - sys.stdout.write(REG_BUFFER_CMD) - - -if __name__ == '__main__': - try: - enter_alternate_mode() - while True: - word = input('Enter something, idiot') - print(word) - finally: - exit_alternate_mode() diff --git a/pyrox/services/cli/alt/print.py b/pyrox/services/cli/alt/print.py deleted file mode 100644 index 1d03981..0000000 --- a/pyrox/services/cli/alt/print.py +++ /dev/null @@ -1,17 +0,0 @@ -from pyrox.services.cli.alt.line import TrackedLine -from pyrox.services.cli.core import ANSIFormatter - - -def update_target_line(line: TrackedLine) -> None: - """Update a targeted line in the console / terminal. - Designed for alternate mode use. - - Args: - line (TrackedLine): :class:`TrackedLine` to update its' value. - """ - if not line.dirty: - return - ANSIFormatter.move_absolute(line.line_no, 1, end='') - ANSIFormatter.clear_to_end_of_line(end='') - print(line.value, end='', flush=True) - line.clean() diff --git a/pyrox/services/cli/app.py b/pyrox/services/cli/app.py deleted file mode 100644 index 42df905..0000000 --- a/pyrox/services/cli/app.py +++ /dev/null @@ -1,89 +0,0 @@ -import msvcrt -from pyrox.interfaces.cli.state import AppState - - -class TerminalApplication: - """The central manager that orchestrates states and terminal lifecycles.""" - - def __init__( - self, - initial_state: AppState | None = None - ): - self.state_tracking: list[AppState] = [] - self._fallback_state = initial_state - if self._fallback_state: - self._fallback_state.app = self - self.current_state = None - self.is_running = False - - def __enter__(self): - """Context manager setup. Prepares terminal settings if needed.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Guarantees terminal restoration even if the app crashes.""" - # Force exit the current state to trigger its clean-up (like disabling alt mode) - if self.current_state: - self.current_state.exit() - - # ANSI fallback to ensure main screen and cursor are restored - print("\033[?1049l\033[?25h", end="", flush=True) - print("\nTerminal connection closed cleanly.") - - def change_state(self, new_state: AppState) -> None: - """Safely transitions from one console state to another.""" - if self.current_state: - self.current_state.exit() - self.state_tracking.append(new_state) - self.state_tracking[-1].app = self - self.current_state = self.state_tracking[-1] - self.current_state.enter() - - def restore_state(self) -> None: - """Transion from current state to last known state. - If no previous state found, safely transtion to MainMenuState - """ - if self.current_state: - self.current_state.exit() - self.state_tracking.pop() - - try: - prev_state = self.state_tracking[-1] - except IndexError: - prev_state = None - - if not prev_state: - if self._fallback_state: - prev_state = self._fallback_state - else: - raise ValueError('No initial state to fall back to!') - self.state_tracking = [prev_state] - - self.current_state = self.state_tracking[-1] - self.current_state.enter() - - def stop(self) -> None: - self.is_running = False - - def run(self, state: AppState | None = None) -> None: - """Launches the main execution loop.""" - self.is_running = True - if state is None and self._fallback_state: - state = self._fallback_state - if state is not None and not self._fallback_state: - self._fallback_state = state - if not state: - raise RuntimeError('Cannot run this application without a known, valid state!') - self.change_state(state) - - if not self.current_state: - raise RuntimeError('Error changing state!') - - # Basic placeholder execution loop - while self.is_running: - try: - # In a real app, use a non-blocking key reader (like `readchar` or `curses`) - char = msvcrt.getch().decode('utf-8', errors='ignore') - self.current_state.handle_input(char) - except (KeyboardInterrupt, SystemExit): - self.stop() diff --git a/pyrox/services/cli/core.py b/pyrox/services/cli/core.py deleted file mode 100644 index 3386d60..0000000 --- a/pyrox/services/cli/core.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Core components and methods for CLI / Terminal interaction - """ -import msvcrt -import os - - -class ANSIFormatter: - """Provides parametrized ANSI escape sequences for CLI formatting.""" - REG_BUFFER_CMD = '\x1b[?1049l' - ALT_BUFFER_CMD = '\x1b[?1049h' - - ESC = "\033[" - ENTER_CHARS = [' ', '\r'] - - def __init__(self) -> None: - raise TypeError("This class is static and cannot be created!") - - @classmethod - def enter_regular_mode(cls, end: str = '\n', flush: bool = False) -> None: - print(cls.REG_BUFFER_CMD, end=end, flush=flush) - - @classmethod - def enter_alt_mode(cls) -> None: - print(cls.ALT_BUFFER_CMD) - - @classmethod - def move_home(cls, end: str = "\n", flush: bool = False) -> None: - """Moves the cursor home.""" - print(f'{cls.ESC}H', end=end, flush=flush) - - @classmethod - def move_absolute(cls, row: int, col: int, end: str = "\n", flush: bool = False) -> None: - """Moves the cursor to a specific row and column.""" - print(f'{cls.ESC}{row};{col}H', end=end, flush=flush) - - @classmethod - def move_to_column(cls, col: int) -> None: - """Moves the cursor to a specific column.""" - print(f'{cls.ESC}{col}G') - - @classmethod - def cursor_up(cls, n: int = 1) -> None: - """Moves the cursor up by n lines.""" - print(f"{cls.ESC}{n}A") - - @classmethod - def cursor_down(cls, n: int = 1) -> None: - """Moves the cursor down by n lines.""" - print(f"{cls.ESC}{n}B") - - @classmethod - def cursor_right(cls, n: int = 1) -> None: - """Moves the cursor right by n columns.""" - print(f"{cls.ESC}{n}C") - - @classmethod - def cursor_left(cls, n: int = 1) -> None: - """Moves the cursor left by n columns.""" - print(f"{cls.ESC}{n}D") - - @classmethod - def hide_cursor(cls) -> None: - """Hide the cursor from the cuser.""" - print(f'{cls.ESC}?25l') - - @classmethod - def show_cursor(cls) -> None: - """Show the cursor to the user.""" - print(f'{cls.ESC}?25h') - - @classmethod - def save_cursor_position(cls) -> None: - """Save cursor position.""" - print(f'{cls.ESC}s') - - @classmethod - def restor_last_cursor_position(cls) -> None: - """Restore the last cursor position saved.""" - print(f'{cls.ESC}u') - - @classmethod - def text_color_256(cls, n: int) -> None: - """Sets foreground color using 256-color palette (0-255).""" - print(f"{cls.ESC}38;5;{n}m") - - @classmethod - def bg_color_256(cls, n: int) -> None: - """Sets background color using 256-color palette (0-255).""" - print(f"{cls.ESC}48;5;{n}m") - - @classmethod - def clear_screen(cls) -> None: - """Clear the screen, leaving cursor in place.""" - print(f'{cls.ESC}2J') - - @classmethod - def clear_to_end_of_line(cls, end: str = '\n', flush: bool = False) -> None: - """Clear from the cursor to the end of the current line.""" - print(f'{cls.ESC}K', end=end, flush=flush) - - @classmethod - def clear_line(cls) -> None: - """Clear the entire current line.""" - print(f'{cls.ESC}2K') - - -def clear(): - os.system('cls' if os.name == 'nt' else 'clear') - clear_input_buffer() - - -def clear_input_buffer(): - # Clear any pending characters sitting in the buffer - while msvcrt.kbhit(): - msvcrt.getch() diff --git a/pyrox/services/cli/menu.py b/pyrox/services/cli/menu.py deleted file mode 100644 index abac7e1..0000000 --- a/pyrox/services/cli/menu.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Callable -from pyrox.core.validators import unsafe_assert_is_type -from pyrox.services.cli.core import clear -from pyrox.services.cli.print import ( - print_header, - print_user_input_prompt, -) - - -class MenuItem: - def __init__( - self, - display_value: str, - callback: Callable - ) -> None: - self.display_value = display_value - self.callback = callback - - -def interactive_menu(items: list[tuple[str, Callable]]) -> None: - """Handle list of callables by displaying them to a user and calling the option they select. - - Args: - items (list[tuple[str, Callable]]): List of string (display) and call-back (function). - - Raises: - ValueError: If tuple index 0 is not a string or tuple index 1 is not a callable function. - """ - clear() - - if not items: - return - - print_header('Select an item below...') - - for index, item in enumerate(items): - unsafe_assert_is_type(item[0], str) - if not callable(item[1]): - raise ValueError('Second item in tuple MUST be callable!') - print(f"[{index}] {item[0]}") - - user_selection = print_user_input_prompt() - if not user_selection: - interactive_menu(items) - try: - index = int(user_selection) - items[index][1]() - return - except IndexError: - input(f'Invalid selection: {user_selection}') - interactive_menu(items) - except ValueError: - input(f'Selection must be a valid number between 0 -> {len(items)}... Got {user_selection}...') - interactive_menu(items) diff --git a/pyrox/services/cli/print.py b/pyrox/services/cli/print.py deleted file mode 100644 index b3db4e8..0000000 --- a/pyrox/services/cli/print.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Print utilities for command line interface -""" -import sys - - -# --- Line generator methods ---------- -def _make_pretty(message: str, header_char: str, fill_char: str) -> str: - return f'{header_char} {fill_char * 3} {message} {fill_char * 10} '.ljust(25, fill_char) - - -def _continue_prompt_str() -> str: - return 'Press "Enter" to continue...' - - -def _user_input_prompt_str() -> str: - return '>>> ' - - -# --- Print methods ---------- -def print_user_input_prompt() -> str: - """General helper method to prompt a user for input feedback. - For consistency across the codebase. - - Returns: - str: The user's response. - """ - return input(_user_input_prompt_str()) - - -def print_continue_prompt() -> str: - """General helper method to prompt the user to press 'Enter' to continue. - For consistency across the codebase. - - Returns: - str: The user's response. - """ - return input(_continue_prompt_str()) - - -def prompt_user_confirm(confirm_message: str) -> bool: - keys = ['y', 'yes'] - print(f'{confirm_message} -> [{keys}]') - return print_user_input_prompt() in keys - - -def print_header(message: str, header_char: str = '#') -> None: - """Print a header line. - This method relies on :method:`print_pretty_line`, passing default characters to work. - - Args: - message (str): Message to print to terminal. - header_char (str, optional): Character to begin header with. Defaults to '#'. - - Example Output: - >>> - # --- This is an example header! ---------- - """ - print_pretty_line(message, header_char) - - -def print_pretty_line(message: str, header_char: str = '#', fill_char: str = ' ') -> None: - """Print a pretty line to the console / terminal. - - Args: - message (str): Message to print. - header_char (str, optional): Header character to begin message with. Defaults to '#'. - fill_char (str, optional): Fill character to wrap message with. Defaults to ' '. - - Raises: - ValueError: If header_char is not a length of 1 (single character). - ValueError: If fill_char is not a length of 1 (single character). - - Example Output: - >>> - print_pretty_line('This is an example message!, fill_char='-') - # --- This is an example pretty message! ---------- - """ - if len(header_char) != 1: - raise ValueError('Header character must be a single character!') - if len(fill_char) != 1: - raise ValueError('Fill character must be a single character!') - print(_make_pretty(message, header_char, fill_char)) - - -# --- Buffer methods ---------- -def buffer_user_input_prompt(buffer: list[str]) -> None: - buffer.append(_user_input_prompt_str()) - - -def buffer_header(buffer: list[str], message: str) -> None: - buffer_pretty_line(buffer, message, '-') - - -def buffer_line(buffer: list[str], message: str = '') -> None: - buffer.append(message) - - -def buffer_pretty_line(buffer: list[str], message: str, header_char: str = '#', fill_char: str = ' ') -> None: - if len(header_char) != 1: - raise ValueError('Header character must be a single character!') - if len(fill_char) != 1: - raise ValueError('Fill character must be a single character!') - buffer.append(_make_pretty(message, '#', fill_char)) - - -# --- Update methods ---------- -def update_console_lines(line_count: int, line_content: list[str]) -> None: - """Update console lines by overwriting existing values and rewriting over the buffer. - This method prevents flicker on the console for 'clear' events. - - Args: - line_count (int): Number of lines to overwrite - line_content (list[str]): List of lines to newly fill the console with - """ - for _ in range(len(line_content)): - sys.stdout.write(f'\033[{line_count}A') - - for line in line_content: - sys.stdout.write(f"\033[K{line}\n") - - sys.stdout.flush() diff --git a/pyrox/services/cli/state.py b/pyrox/services/cli/state.py deleted file mode 100644 index 32b4778..0000000 --- a/pyrox/services/cli/state.py +++ /dev/null @@ -1,187 +0,0 @@ -from pyrox.interfaces.cli.state import AppState -from pyrox.services.cli.core import ANSIFormatter -from pyrox.services.cli.print import _make_pretty -from pyrox.services.cli.menu import MenuItem -from pyrox.services.cli.alt.line import TrackedLine -from pyrox.services.cli.alt.print import update_target_line - - -class _BaseState(AppState): - def enter(self): - self.is_alive = True - self.render() - - def exit(self) -> None: - self.is_alive = False - - -class MainMenuState(_BaseState): - def handle_input(self, key): - if key == "e": - # Transition to the editor (which uses Alt Mode) - self.app.change_state(AltEditorState(self.app)) - elif key == "q": - self.app.stop() - - def render(self): - print("\n=== MAIN MENU ===") - print("[e] Open Advanced Editor (Alt Mode)") - print("[q] Quit Application") - - -class AltEditorState(_BaseState): - def enter(self): - # Enable Alternate Screen Buffer using ANSI escape codes - print("\033[?1049h\033[H", end="", flush=True) - self.render() - - def handle_input(self, key): - if key == "b": - # Go back to main menu - self.app.change_state(MainMenuState(self.app)) - - def render(self): - # Clear screen and draw editor UI - print("\033[2J\033[H", end="") - print("--- ADVANCED ALT-MODE EDITOR ---") - print("Type text here... (Simulated)") - print("\nPress [b] to return to Main Menu.") - - def exit(self): - # Disable Alternate Screen Buffer safely when leaving - print("\033[?1049l", end="", flush=True) - - -class InteractiveMenuState(_BaseState): - - def __init__( - self, - app_context, - title: str, - items: list[MenuItem], - footer: str = '', - pointer_char: str = '<', - as_root: bool = False - ): - super().__init__(app_context) - self.title = title - self.items = items - self.length = len(items) - self.pointer = 0 - line_items = [] - for x in range(self.length): - text = _make_pretty(self.items[x].display_value, '#', ' ') - line_items.append(TrackedLine(2+x, text)) - - self.lines = { - 'header': TrackedLine(1, _make_pretty(title, '#', '-')), - 'list': line_items, - 'status': TrackedLine(3 + self.length, ''), - 'footer': TrackedLine(4 + self.length, footer), - } - self._can_exit = not as_root - if self._can_exit: - self.lines['end'] = TrackedLine(5 + self.length, 'Press "e" to exit...') - - if len(pointer_char) != 1: - raise ValueError('Pointer character must be a single character string!') - self.pointer_char = pointer_char - - @property - def status(self) -> TrackedLine: - return self.lines['status'] - - def all_lines(self) -> list[TrackedLine]: - lines = [] - for value in self.lines.values(): - if isinstance(value, list): - lines.extend(value) - elif isinstance(value, TrackedLine): - lines.append(value) - else: - raise ValueError(f'Unexpected type found! {type(value)}') - return lines - - # --- Pointer Manipulation ---------- - def _strip_pointer(self, line: TrackedLine) -> None: - """Remove the pointer character from a given line. """ - if not line.value.strip().endswith(self.pointer_char): - return - line.value = line.value.removesuffix(self.pointer_char) - - def _append_pointer(self, line: TrackedLine) -> None: - """Append pointer character to a given line.""" - if line.value.strip().endswith(self.pointer_char): - return - line.value = line.value + f'{self.pointer_char}' - - def _change_line_position(self, offset: int) -> None: - self._strip_pointer(self.lines['list'][self.pointer]) - self.pointer += offset - if self.pointer >= self.length: - self.pointer = 0 - if self.pointer < 0: - self.pointer = self.length - 1 - self._append_pointer(self.lines['list'][self.pointer]) - - def _increment_pointer(self): - self._change_line_position(1) - - def _decrement_pointer(self): - self._change_line_position(-1) - - def _set_pointer(self, pos: int) -> None: - """Set pointer to a specified value.""" - self._change_line_position(-(self.pointer - pos)) - - # --- Execute ---------- - def execute_line_item(self): - ret_value = self.items[self.pointer].callback() - if ret_value is not None: - self.status.value = f'Got value from callback: {ret_value}' - - # --- Abstract Fullfillment ---------- - def enter(self): - super().enter() - # Enable Alternate Screen Buffer using ANSI escape codes - ANSIFormatter.enter_alt_mode() - ANSIFormatter.clear_screen() - ANSIFormatter.move_home(end="", flush=True) - ANSIFormatter.hide_cursor() - self._set_pointer(0) - self.render(redraw=True) - - def handle_input(self, key): - if key == 'e': - if not self._can_exit: - return - self.app.restore_state() - if key == 'j': - self._increment_pointer() - if key == 'k': - self._decrement_pointer() - if key in ANSIFormatter.ENTER_CHARS: - self.execute_line_item() - self.render() - - def render(self, redraw: bool = False): - # Don't process if our app killed us. - if not self.is_alive: - return - # Clear screen and draw editor UI - for line in self.all_lines(): - if redraw: - line.mark_dirty() - update_target_line(line) - - def exit(self): - # Disable Alternate Screen Buffer safely when leaving - ANSIFormatter.enter_regular_mode(end="", flush=True) - ANSIFormatter.clear_screen() - ANSIFormatter.move_home(end="", flush=True) - ANSIFormatter.show_cursor() - super().exit() - - def mark_dirty(self) -> None: - for line in self.all_lines(): - line.mark_dirty() diff --git a/pyrox/services/cli/tfixture.py b/pyrox/services/cli/tfixture.py deleted file mode 100644 index d531792..0000000 --- a/pyrox/services/cli/tfixture.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Test fixture file so i dont have to keep changing envs while developing this in my core. -""" -from pyrox.services.cli.app import TerminalApplication -from pyrox.services.cli.menu import MenuItem -from pyrox.services.cli.state import InteractiveMenuState - - -if __name__ == '__main__': - sub_state = InteractiveMenuState( - None, - 'Sub Menu', - [ - MenuItem('Child item 1', lambda: 'child item 1 selected'), - MenuItem('Child item 2', lambda: 'child item 2 selected'), - MenuItem('Child item 3', lambda: 'child item 3 selected'), - ] - ) - main_state = InteractiveMenuState( - None, - 'Interactive Menu Demo', - [ - MenuItem('Go to sub-menu', lambda: app.change_state(sub_state)), - MenuItem('Item 2', lambda: 'item 2 selected'), - MenuItem('Item 3', lambda: 'item 3 selected'), - MenuItem('Exit', lambda: exit(0)) - ] - ) - with TerminalApplication(initial_state=main_state) as app: - app.run()