From 0c91fe6923d2e4b12e076deb7b516cf2c2582537 Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Thu, 4 Dec 2025 22:47:20 +0100 Subject: [PATCH] Updated UI to be Custom Renderd --- pyproject.toml | 7 +- src/statsman/app.py | 111 -------- src/statsman/cli.py | 12 +- src/statsman/system_monitor.py | 5 +- src/statsman/ui/__init__.py | 4 + src/statsman/ui/app.py | 75 ++++++ src/statsman/ui/charts.py | 166 ------------ src/statsman/ui/components/__init__.py | 15 ++ src/statsman/ui/components/cpu_cores.py | 54 ++++ src/statsman/ui/components/header_footer.py | 43 +++ src/statsman/ui/components/memory_display.py | 66 +++++ src/statsman/ui/components/network_display.py | 87 ++++++ src/statsman/ui/components/process_list.py | 86 ++++++ src/statsman/ui/components/system_overview.py | 51 ++++ src/statsman/ui/dashboard.py | 123 --------- src/statsman/ui/layouts/__init__.py | 19 ++ .../ui/layouts/content_size_fitter.py | 26 ++ src/statsman/ui/layouts/drawing_primitives.py | 226 ++++++++++++++++ src/statsman/ui/layouts/grid_layout.py | 82 ++++++ src/statsman/ui/layouts/horizontal_layout.py | 134 ++++++++++ src/statsman/ui/layouts/layout_element.py | 25 ++ src/statsman/ui/layouts/layout_manager.py | 52 ++++ .../ui/layouts/terminal_controller.py | 116 ++++++++ src/statsman/ui/layouts/vertical_layout.py | 134 ++++++++++ src/statsman/ui/renderer.py | 253 ++++++++++++++++++ 25 files changed, 1562 insertions(+), 410 deletions(-) delete mode 100644 src/statsman/app.py create mode 100644 src/statsman/ui/app.py delete mode 100644 src/statsman/ui/charts.py create mode 100644 src/statsman/ui/components/__init__.py create mode 100644 src/statsman/ui/components/cpu_cores.py create mode 100644 src/statsman/ui/components/header_footer.py create mode 100644 src/statsman/ui/components/memory_display.py create mode 100644 src/statsman/ui/components/network_display.py create mode 100644 src/statsman/ui/components/process_list.py create mode 100644 src/statsman/ui/components/system_overview.py delete mode 100644 src/statsman/ui/dashboard.py create mode 100644 src/statsman/ui/layouts/__init__.py create mode 100644 src/statsman/ui/layouts/content_size_fitter.py create mode 100644 src/statsman/ui/layouts/drawing_primitives.py create mode 100644 src/statsman/ui/layouts/grid_layout.py create mode 100644 src/statsman/ui/layouts/horizontal_layout.py create mode 100644 src/statsman/ui/layouts/layout_element.py create mode 100644 src/statsman/ui/layouts/layout_manager.py create mode 100644 src/statsman/ui/layouts/terminal_controller.py create mode 100644 src/statsman/ui/layouts/vertical_layout.py create mode 100644 src/statsman/ui/renderer.py diff --git a/pyproject.toml b/pyproject.toml index 13138b8..c3eeb86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "statsman" -version = "0.1.3" -description = "A real-time terminal-based system monitoring tool with ASCII visualizations" +version = "0.2.0" +description = "A terminal-based system monitoring tool with manual UI rendering, full-screen support, and dynamic layout management" authors = [{name = "Exil Productions", email = "exil.productions.business@gmail.com"}] license = {text = "MIT"} readme = "README.md" requires-python = ">=3.8" classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", @@ -27,7 +27,6 @@ classifiers = [ ] dependencies = [ "psutil>=5.9.0", - "rich>=13.0.0", "click>=8.0.0", ] diff --git a/src/statsman/app.py b/src/statsman/app.py deleted file mode 100644 index 8445183..0000000 --- a/src/statsman/app.py +++ /dev/null @@ -1,111 +0,0 @@ -import sys -import time -import signal -import threading - -import click -from rich.console import Console -from rich.live import Live - -from .system_monitor import SystemMonitor -from .ui.dashboard import Dashboard - - -class StatsManApp: - def __init__(self, refresh_rate: float = 1.0, no_color: bool = False): - self.refresh_rate = refresh_rate - self.no_color = no_color - - self.console = Console(color_system=None if no_color else "auto") - self.dashboard = Dashboard(self.console, no_color) - self.live = None - self.running = False - self.paused = False - - signal.signal(signal.SIGINT, self._signal_handler) - signal.signal(signal.SIGTERM, self._signal_handler) - - def _signal_handler(self, signum, frame): - self.running = False - if self.live: - self.live.stop() - - def _handle_keyboard_input(self): - old_settings = None - termios = None - try: - import select - import termios - import tty - - old_settings = termios.tcgetattr(sys.stdin) - tty.setraw(sys.stdin.fileno()) - - while self.running: - if select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []): - char = sys.stdin.read(1) - - if char.lower() == 'q': - self.running = False - break - elif char.lower() == 'p': - self.paused = not self.paused - elif char.lower() == 'c': - self.dashboard.set_process_sort('cpu') - elif char.lower() == 'm': - self.dashboard.set_process_sort('memory') - elif char.lower() == 'r': - self.dashboard.set_process_sort('cpu') - - time.sleep(0.1) - - except (ImportError, OSError): - pass - - finally: - try: - if old_settings is not None and termios is not None: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) - except: - pass - - def run(self): - self.running = True - - def on_resize(sig, frame): - self.console.clear() - if self.live: - self.live.update(self.dashboard.render(), refresh=True) - - signal.signal(signal.SIGWINCH, on_resize) - - keyboard_thread = threading.Thread(target=self._handle_keyboard_input, daemon=True) - keyboard_thread.start() - - try: - with Live( - self.dashboard.render(), - console=self.console, - refresh_per_second=1.0 / self.refresh_rate, - screen=True, - auto_refresh=True, - ) as self.live: - self.live = self.live - - while self.running: - if not self.paused: - layout = self.dashboard.render() - self.live.update(layout) - - time.sleep(self.refresh_rate) - - except KeyboardInterrupt: - pass - - finally: - self.running = False - if self.live: - self.live.stop() - - self.console.clear() - self.console.print("StatsMan - Goodbye!", justify="center") \ No newline at end of file diff --git a/src/statsman/cli.py b/src/statsman/cli.py index 3f3dd42..83b6058 100644 --- a/src/statsman/cli.py +++ b/src/statsman/cli.py @@ -1,5 +1,5 @@ import click -from .app import StatsManApp +from .ui.app import StatsManApp @click.command() @@ -16,9 +16,15 @@ from .app import StatsManApp default=False, help="Disable colored output", ) +@click.option( + "--bg-color", + default="black", + help="Terminal background color (default: black)", +) @click.version_option(version="0.1.0", prog_name="statsman") -def main(refresh_rate: float, no_color: bool) -> None: - app = StatsManApp(refresh_rate=refresh_rate, no_color=no_color) +def main(refresh_rate: float, no_color: bool, bg_color: str) -> None: + """StatsMan - Terminal System Monitor with Manual UI""" + app = StatsManApp(refresh_rate=refresh_rate, no_color=no_color, bg_color=bg_color) app.run() diff --git a/src/statsman/system_monitor.py b/src/statsman/system_monitor.py index 5fa6c3f..947baea 100644 --- a/src/statsman/system_monitor.py +++ b/src/statsman/system_monitor.py @@ -49,7 +49,6 @@ class NetworkInfo: @dataclass class ProcessInfo: - """Process information.""" pid: int name: str cpu_percent: float @@ -77,7 +76,7 @@ class SystemMonitor: freq = psutil.cpu_freq().current if psutil.cpu_freq() else 0.0 except (AttributeError, OSError): freq = 0.0 - #linux only + try: load_avg = list(psutil.getloadavg()) except (AttributeError, OSError): @@ -217,4 +216,4 @@ class SystemMonitor: return list(self.memory_history) def get_network_history(self) -> List[Dict[str, int]]: - return list(self.network_history) \ No newline at end of file + return list(self.network_history) diff --git a/src/statsman/ui/__init__.py b/src/statsman/ui/__init__.py index e69de29..15dba75 100644 --- a/src/statsman/ui/__init__.py +++ b/src/statsman/ui/__init__.py @@ -0,0 +1,4 @@ +""" +UI package for StatsMan +Manual terminal UI implementation +""" \ No newline at end of file diff --git a/src/statsman/ui/app.py b/src/statsman/ui/app.py new file mode 100644 index 0000000..319334e --- /dev/null +++ b/src/statsman/ui/app.py @@ -0,0 +1,75 @@ +import sys +import time +import signal +import threading +from typing import Optional + +from .renderer import StatsManRenderer + + +class KeyboardHandler: + + def __init__(self, renderer: StatsManRenderer): + self.renderer = renderer + + def handle_key(self, char: str) -> bool: + char_lower = char.lower() + + if char_lower == 'q': + self.renderer.running = False + return False + elif char_lower == 'p': + + pass + elif char_lower == 'c': + self.renderer.set_process_sort('cpu') + elif char_lower == 'm': + self.renderer.set_process_sort('memory') + + return True + + def start_input_thread(self): + input_thread = threading.Thread(target=self.renderer.handle_keyboard_input, + args=(self.handle_key,), daemon=True) + input_thread.start() + + +class StatsManApp: + + def __init__(self, refresh_rate: float = 1.0, no_color: bool = False, bg_color: str = "black"): + self.refresh_rate = refresh_rate + self.no_color = no_color + self.bg_color = bg_color + + + self.renderer = StatsManRenderer(no_color, bg_color) + self.keyboard_handler = KeyboardHandler(self.renderer) + + def run(self): + + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def on_resize(sig, frame): + if self.renderer.running: + + self.renderer._handle_resize() + + signal.signal(signal.SIGWINCH, on_resize) + + + self.renderer.initialize() + self.keyboard_handler.start_input_thread() + + try: + while self.renderer.running: + self.renderer.render() + time.sleep(self.refresh_rate) + except KeyboardInterrupt: + pass + finally: + self.renderer.cleanup() + print("\n[bold cyan]StatsMan – See you later![/]\n") + + def _signal_handler(self, signum, frame): + self.renderer.running = False \ No newline at end of file diff --git a/src/statsman/ui/charts.py b/src/statsman/ui/charts.py deleted file mode 100644 index 5e2c5d3..0000000 --- a/src/statsman/ui/charts.py +++ /dev/null @@ -1,166 +0,0 @@ -from rich.console import Console, Group -from rich.progress import Progress, BarColumn, TextColumn -from rich.table import Table -from rich.panel import Panel -from rich.text import Text -from rich.align import Align -from typing import List, Dict, Any -import math - - -class ChartRenderer: - def __init__(self, console: Console): - self.console = console - - def create_sparkline(self, data: List[float], width: int = 80, height: int = 8) -> str: - if not data: - return " " * width - - min_val = min(data) - max_val = max(data) - range_val = max_val - min_val if max_val != min_val else 1 - - spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] - - recent_data = data[-width:] if len(data) > width else data - - sparkline = "" - for value in recent_data: - normalized = (value - min_val) / range_val - char_index = min(int(normalized * len(spark_chars)), len(spark_chars) - 1) - sparkline += spark_chars[max(0, char_index)] - - return sparkline - - def create_vertical_bars(self, data: Dict[str, float], height: int = 12, width: int = 60) -> Panel: - if not data: - return Panel("No data", border_style="red") - - max_val = max(data.values()) if data.values() else 1 - labels = list(data.keys()) - values = list(data.values()) - - bar_widths = [int((v / max_val) * width) if max_val > 0 else 0 for v in values] - - lines = [] - for level in range(height, 0, -1): - line = "" - for bar_width in bar_widths: - threshold = (level / height) * width - if bar_width >= threshold: - line += "█" - else: - line += " " - line += " " - lines.append(line) - - label_line = "" - for label in labels: - label_line += f"{label[:8]:8} " - - chart_text = "\n".join(lines + [label_line]) - return Panel(chart_text, border_style="blue") - - def create_horizontal_bars(self, data: Dict[str, float], max_width: int = 70) -> Panel: - if not data: - return Panel("No data", border_style="red") - - max_val = max(data.values()) if data.values() else 1 - - lines = [] - for label, value in data.items(): - bar_width = int((value / max_val) * max_width) if max_val > 0 else 0 - bar = "█" * bar_width + "░" * (max_width - bar_width) - lines.append(f"{label:6} {bar} {value:5.1f}%") - - return Panel("\n".join(lines), border_style="green") - - def create_mini_process_table(self, processes: List[Any], limit: int = 12, console_width: int = 80) -> Panel: - if not processes: - return Panel("No processes", border_style="red") - - sorted_processes = sorted(processes, key=lambda p: p.cpu_percent, reverse=True) - - max_name_width = max(10, min(20, (console_width - 35) // 2)) - bar_width = max(5, min(10, (console_width - 25) // 4)) - - header = f"{'PID':<7} {'PROCESS':<20} {'CPU':<12} {'MEM':<12}" - separator = "=" * min(console_width - 4, 75) - lines = [header, separator] - - for proc in sorted_processes[:limit]: - cpu_bar = self._create_mini_bar(proc.cpu_percent, bar_width) - mem_bar = self._create_mini_bar(proc.memory_percent, bar_width) - - name = (proc.name[:18] + "..") if len(proc.name) > 20 else proc.name.ljust(20) - lines.append(f"{proc.pid:<7} {name} {cpu_bar} {mem_bar}") - - return Panel("\n".join(lines), title="Top Processes", border_style="magenta") - - def _create_mini_bar(self, percentage: float, width: int = 10) -> str: - filled = int((percentage / 100) * width) - bar = "█" * filled + "░" * (width - filled) - return bar - - def create_system_gauges(self, cpu_info: Any, memory_info: Any, disk_info: Any, console_width: int = 80) -> Panel: - gauges = [] - gauge_width = max(15, min(30, console_width // 4)) - - cpu_gauge = self._create_gauge(cpu_info.percent, "CPU", gauge_width) - gauges.append(cpu_gauge) - - mem_gauge = self._create_gauge(memory_info.percent, "MEM", gauge_width) - gauges.append(mem_gauge) - - disk_gauge = self._create_gauge(disk_info.percent, "DSK", gauge_width) - gauges.append(disk_gauge) - - return Panel(Group(*gauges), border_style="cyan") - - def _create_gauge(self, percentage: float, label: str, width: int = 30) -> Text: - filled = int((percentage / 100) * width) - bar = "█" * filled + "░" * (width - filled) - return Text.from_markup(f"[bold]{label}:[/bold] {bar} {percentage:5.1f}%") - - def create_network_visualization(self, network_info: Any, console_width: int = 80) -> Panel: - sent_mb = network_info.bytes_sent / (1024 * 1024) - recv_mb = network_info.bytes_recv / (1024 * 1024) - - network_data = { - "UPLOAD": min(sent_mb * 10, 100), - "DOWNLOAD": min(recv_mb * 10, 100), - } - - bar_width = max(15, min(70, console_width // 2)) - return self.create_horizontal_bars(network_data, max_width=bar_width) - - def create_cpu_core_visualization(self, cpu_info: Any, console_width: int = 80) -> Panel: - if not cpu_info.percent_per_core: - return Panel("No core data", border_style="red") - - core_data = {} - for i, core_percent in enumerate(cpu_info.percent_per_core): - core_data[f"C{i:02d}"] = core_percent - - bar_width = max(20, min(40, console_width // 8)) - return self.create_vertical_bars(core_data, height=8, width=bar_width) - - def create_memory_breakdown(self, memory_info: Any, console_width: int = 80) -> Panel: - used_gb = memory_info.used / (1024**3) - total_gb = memory_info.total / (1024**3) - - memory_data = { - "USED": (used_gb / total_gb) * 100, - "FREE": ((total_gb - used_gb) / total_gb) * 100, - } - - bar_width = max(15, min(70, console_width // 2)) - return self.create_horizontal_bars(memory_data, max_width=bar_width) - - def format_bytes(self, bytes_value: int) -> str: - bytes_float = float(bytes_value) - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: - if bytes_float < 1024.0: - return f"{bytes_float:.1f} {unit}" - bytes_float /= 1024.0 - return f"{bytes_float:.1f} PB" \ No newline at end of file diff --git a/src/statsman/ui/components/__init__.py b/src/statsman/ui/components/__init__.py new file mode 100644 index 0000000..0cc6caf --- /dev/null +++ b/src/statsman/ui/components/__init__.py @@ -0,0 +1,15 @@ +from .system_overview import SystemOverview +from .cpu_cores import CPUCores +from .memory_display import MemoryDisplay +from .network_display import NetworkDisplay +from .process_list import ProcessList +from .header_footer import HeaderFooter + +__all__ = [ + 'SystemOverview', + 'CPUCores', + 'MemoryDisplay', + 'NetworkDisplay', + 'ProcessList', + 'HeaderFooter' +] \ No newline at end of file diff --git a/src/statsman/ui/components/cpu_cores.py b/src/statsman/ui/components/cpu_cores.py new file mode 100644 index 0000000..a9ed24d --- /dev/null +++ b/src/statsman/ui/components/cpu_cores.py @@ -0,0 +1,54 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class CPUCores(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 15, min_height: int = 5, preferred_width: int = 30, + preferred_height: int = 8, flexible_width: int = 1, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + + def render(self, x: int, y: int, width: int, height: int, cpu_info: Any): + + width, height = max(15, width), max(5, height) + + self.drawing.draw_box(x, y, width, height, "CPU Cores", "blue") + + if not cpu_info.percent_per_core: + self.drawing.draw_centered_text(x + width//2, y + height//2, "No core data", "white") + return + + + + chart_x = x + 2 + chart_y = y + 2 + chart_width = width - 4 + chart_height = height - 4 + + if chart_height <= 0 or chart_width <= 0: + self.drawing.draw_centered_text(x + width//2, y + height//2, "Too small", "white") + return + + total_cores = len(cpu_info.percent_per_core) + max_cores = min(total_cores, chart_width // 3) + + if max_cores == 0: + self.drawing.draw_centered_text(x + width//2, y + height//2, "Too narrow", "white") + return + + core_data = {} + for i in range(max_cores): + core_data[f"C{i}"] = cpu_info.percent_per_core[i] + + self.drawing.draw_vertical_bars(chart_x, chart_y, chart_width, chart_height, core_data, "blue") + + if height > 5: + usage_text = f"CPU: {cpu_info.percent:.1f}%" if width > 20 else f"{cpu_info.percent:.0f}%" + text_y = y + height - 2 + self.drawing.draw_centered_text(x + width//2, text_y, usage_text, "bright_blue") \ No newline at end of file diff --git a/src/statsman/ui/components/header_footer.py b/src/statsman/ui/components/header_footer.py new file mode 100644 index 0000000..e15ecb3 --- /dev/null +++ b/src/statsman/ui/components/header_footer.py @@ -0,0 +1,43 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class HeaderFooter(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 20, min_height: int = 3, preferred_width: int = 80, + preferred_height: int = 3, flexible_width: int = 1, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + + def render_header(self, x: int, y: int, width: int, height: int): + + if width < 30: + title = "SM" + elif width < 50: + title = "StatsMan" + else: + title = "StatsMan - System Monitor" + + self.drawing.draw_box(x, y, width, height, title, "bright_blue") + + panel_text = "Real-time System Metrics" + self.drawing.draw_centered_text(x + width//2, y + height//2, panel_text, "bright_blue") + + def render_footer(self, x: int, y: int, width: int, height: int): + + if width < 25: + help_text = "q:quit p:pause" + elif width < 40: + help_text = "q:quit p:pause c:cpu m:mem" + elif width < 60: + help_text = "q:quit │ p:pause │ c:cpu │ m:mem" + else: + help_text = "q:quit │ p:pause │ c:sort CPU │ m:sort MEM" + + self.drawing.draw_box(x, y, width, height, "", "bright_black") + self.drawing.draw_centered_text(x + width//2, y + height//2, help_text, "bright_cyan") \ No newline at end of file diff --git a/src/statsman/ui/components/memory_display.py b/src/statsman/ui/components/memory_display.py new file mode 100644 index 0000000..cf2a5f8 --- /dev/null +++ b/src/statsman/ui/components/memory_display.py @@ -0,0 +1,66 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class MemoryDisplay(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 20, min_height: int = 5, preferred_width: int = 30, + preferred_height: int = 8, flexible_width: int = 1, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + + def render(self, x: int, y: int, width: int, height: int, mem_info: Any): + + width, height = max(20, width), max(5, height) + + self.drawing.draw_box(x, y, width, height, "Memory", "green") + + used_gb = mem_info.used / (1024**3) + total_gb = mem_info.total / (1024**3) + available_gb = mem_info.available / (1024**3) + + + content_width = width - 4 + content_height = height - 2 + current_y = y + 1 + + + + elements_count = 3 if width < 25 or height < 10 else 4 + available_lines = height - 2 + + progress_bar_lines = 4 if elements_count > 3 else 0 + effective_lines = available_lines - progress_bar_lines + spacing = max(1, effective_lines // (elements_count - 1)) if elements_count > 1 else available_lines + + if width < 25 or height < 6: + + elements = [ + (f"U:{used_gb:.1f}G", "red"), + (f"A:{available_gb:.1f}G", "green"), + ("", "green") + ] + else: + + elements = [ + (f"Used: {used_gb:.1f}GB", "red"), + (f"Available: {available_gb:.1f}GB", "green"), + (f"Total: {total_gb:.1f}GB", "blue"), + ("", "green") + ] + + for i, (text, color) in enumerate(elements): + element_y = y + 1 + (i * spacing) + if element_y >= y + height - 1: + break + + if text: + self.drawing.draw_at(x + 2, element_y, text, color) + else: + bar_text = "Memory" if len(elements) > 3 else "" + self.drawing.draw_progress_bar(x + 2, element_y, content_width, mem_info.percent, bar_text, "green") \ No newline at end of file diff --git a/src/statsman/ui/components/network_display.py b/src/statsman/ui/components/network_display.py new file mode 100644 index 0000000..dad47ee --- /dev/null +++ b/src/statsman/ui/components/network_display.py @@ -0,0 +1,87 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class NetworkDisplay(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 20, min_height: int = 5, preferred_width: int = 30, + preferred_height: int = 8, flexible_width: int = 1, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + + def render(self, x: int, y: int, width: int, height: int, net_info: Any): + + width, height = max(20, width), max(5, height) + + self.drawing.draw_box(x, y, width, height, "Network", "yellow") + + sent_mb = net_info.bytes_sent / (1024 * 1024) + recv_mb = net_info.bytes_recv / (1024 * 1024) + + + content_width = width - 4 + content_height = height - 2 + current_y = y + 1 + + + + if height < 6: + + self.drawing.draw_at(x + 2, y + 1, f"↑{sent_mb:.1f}M ↓{recv_mb:.1f}M", "yellow") + return + + + available_height = height - 2 + + if available_height >= 8: + + text_y = y + 1 + upload_bar_y = y + 3 + download_bar_y = y + 7 + + + if width < 25: + self.drawing.draw_at(x + 2, text_y, f"↑{sent_mb:.1f}M ↓{recv_mb:.1f}M", "yellow") + else: + self.drawing.draw_at(x + 2, text_y, f"Upload: {self._format_bytes(net_info.bytes_sent)}", "green") + self.drawing.draw_at(x + 2, text_y + 1, f"Download: {self._format_bytes(net_info.bytes_recv)}", "blue") + + + bar_text = "UP" if width < 25 else "UPLOAD" + self.drawing.draw_progress_bar(x + 2, upload_bar_y, content_width, min(sent_mb * 10, 100), bar_text, "green") + + bar_text = "DOWN" if width < 25 else "DOWNLOAD" + self.drawing.draw_progress_bar(x + 2, download_bar_y, content_width, min(recv_mb * 10, 100), bar_text, "blue") + + elif available_height >= 5: + + text_y = y + 1 + bar_y = y + 3 + + + if width < 25: + self.drawing.draw_at(x + 2, text_y, f"↑{sent_mb:.1f}M ↓{recv_mb:.1f}M", "yellow") + else: + self.drawing.draw_at(x + 2, text_y, f"UL: {self._format_bytes(net_info.bytes_sent)}", "green") + self.drawing.draw_at(x + 2, text_y + 1, f"DL: {self._format_bytes(net_info.bytes_recv)}", "blue") + + + combined_rate = (sent_mb + recv_mb) / 2 + self.drawing.draw_progress_bar(x + 2, bar_y, content_width, min(combined_rate * 10, 100), "NET", "cyan") + + else: + + self.drawing.draw_at(x + 2, y + 1, f"↑{sent_mb:.1f} ↓{recv_mb:.1f}", "yellow") + + def _format_bytes(self, bytes_value: int) -> str: + bytes_float = float(bytes_value) + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_float < 1024.0: + return f"{bytes_float:.1f} {unit}" + bytes_float /= 1024.0 + return f"{bytes_float:.1f} PB" \ No newline at end of file diff --git a/src/statsman/ui/components/process_list.py b/src/statsman/ui/components/process_list.py new file mode 100644 index 0000000..4a04860 --- /dev/null +++ b/src/statsman/ui/components/process_list.py @@ -0,0 +1,86 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class ProcessList(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 30, min_height: int = 8, preferred_width: int = 80, + preferred_height: int = 15, flexible_width: int = 1, flexible_height: int = 1, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + self.sort_by = "cpu" + + def render(self, x: int, y: int, width: int, height: int, processes: List[Any]): + + width, height = max(30, width), max(5, height) + + self.drawing.draw_box(x, y, width, height, "Top Processes", "magenta") + + + available_lines = height - 3 + limit = max(1, min(available_lines, 20)) + + + if self.sort_by == "memory": + procs = sorted(processes, key=lambda p: p.memory_percent, reverse=True) + else: + procs = sorted(processes, key=lambda p: p.cpu_percent, reverse=True) + + + content_x = x + 2 + content_width = width - 4 + current_y = y + 1 + + + if width < 40: + + header = "PID PROC C% M%" + separator = "=" * min(content_width, len(header)) + if current_y < y + height - 1: + self.drawing.draw_at(content_x, current_y, header, "white") + current_y += 1 + if current_y < y + height - 1: + self.drawing.draw_at(content_x, current_y, separator, "white") + current_y += 1 + + for i, proc in enumerate(procs[:limit]): + if current_y >= y + height - 1: + break + name = (proc.name[:4] + ".") if len(proc.name) > 5 else proc.name.ljust(5) + line = f"{proc.pid:<4} {name} {proc.cpu_percent:<2.0f} {proc.memory_percent:<2.0f}" + + if len(line) > content_width: + line = line[:content_width] + self.drawing.draw_at(content_x, current_y, line, "white") + current_y += 1 + else: + + header = "PID PROCESS CPU% MEM%" + separator = "=" * min(content_width, len(header)) + if current_y < y + height - 1: + self.drawing.draw_at(content_x, current_y, header, "white") + current_y += 1 + if current_y < y + height - 1: + self.drawing.draw_at(content_x, current_y, separator, "white") + current_y += 1 + + for i, proc in enumerate(procs[:limit]): + if current_y >= y + height - 1: + break + name_width = max(10, (content_width - 25) // 2) + name = (proc.name[:name_width-2] + "..") if len(proc.name) > name_width else proc.name.ljust(name_width) + line = f"{proc.pid:<6} {name} {proc.cpu_percent:<5.1f} {proc.memory_percent:<5.1f}" + + if len(line) > content_width: + line = line[:content_width] + self.drawing.draw_at(content_x, current_y, line, "white") + current_y += 1 + + def set_sort_method(self, sort_by: str): + if sort_by in ("cpu", "memory"): + self.sort_by = sort_by \ No newline at end of file diff --git a/src/statsman/ui/components/system_overview.py b/src/statsman/ui/components/system_overview.py new file mode 100644 index 0000000..26fc98e --- /dev/null +++ b/src/statsman/ui/components/system_overview.py @@ -0,0 +1,51 @@ +import sys +from typing import Dict, List, Optional, Any +from ..layouts import TerminalController, DrawingPrimitives, LayoutElement + + +class SystemOverview(LayoutElement): + + def __init__(self, terminal: TerminalController, drawing: DrawingPrimitives, + min_width: int = 20, min_height: int = 5, preferred_width: int = 40, + preferred_height: int = 8, flexible_width: int = 1, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + super().__init__(min_width, min_height, preferred_width, preferred_height, + flexible_width, flexible_height, anchor, justification) + self.terminal = terminal + self.drawing = drawing + + def render(self, x: int, y: int, width: int, height: int, + cpu_info: Any, mem_info: Any, disk_info: Any): + + width, height = max(20, width), max(5, height) + + + self.drawing.draw_box(x, y, width, height, "System", "cyan") + + + + available_width = width - 4 + gauge_width = max(10, available_width // 3) + spacing = 2 + gauge_y = y + 2 + + total_width = 3 * gauge_width + 2 * spacing + if total_width > available_width: + gauge_width = max(10, (available_width - 2 * spacing) // 3) + total_width = 3 * gauge_width + 2 * spacing + + start_x = x + 2 + (available_width - total_width) // 2 + + for i in range(3): + gauge_x = start_x + i * (gauge_width + spacing) + for line in range(4): + clear_y = gauge_y + line + if clear_y < y + height - 1: + clear_width = min(gauge_width, x + width - gauge_x - 1) + if clear_width > 0: + self.terminal.move_cursor(gauge_x, clear_y) + sys.stdout.write(" " * clear_width) + + self.drawing.draw_progress_bar(start_x, gauge_y, gauge_width, cpu_info.percent, "CPU", "red") + self.drawing.draw_progress_bar(start_x + gauge_width + spacing, gauge_y, gauge_width, mem_info.percent, "MEM", "green") + self.drawing.draw_progress_bar(start_x + 2*(gauge_width + spacing), gauge_y, gauge_width, disk_info.percent, "DSK", "yellow") \ No newline at end of file diff --git a/src/statsman/ui/dashboard.py b/src/statsman/ui/dashboard.py deleted file mode 100644 index 1c721a8..0000000 --- a/src/statsman/ui/dashboard.py +++ /dev/null @@ -1,123 +0,0 @@ -from rich.console import Console, Group -from rich.layout import Layout -from rich.panel import Panel -from rich.text import Text -from rich.align import Align -from typing import Optional - -from ..system_monitor import SystemMonitor -from .charts import ChartRenderer - - -class Dashboard: - def __init__(self, console: Optional[Console] = None, no_color: bool = False): - self.console = console or Console(color_system=None if no_color else "auto") - self.monitor = SystemMonitor(history_size=120) - self.charts = ChartRenderer(self.console) - self.layout = Layout() - self.sort_processes_by = "cpu" - - def _make_layout(self) -> Layout: - """Create responsive layout based on current terminal size""" - layout = Layout() - layout.split_column( - Layout(name="header", size=3), - Layout(name="body", ratio=1), - Layout(name="footer", size=3), - ) - - layout["body"].split_column( - Layout(name="top", ratio=2), - Layout(name="middle", ratio=1), - Layout(name="processes", ratio=2), - ) - - layout["top"].split_row( - Layout(name="gauges", ratio=1), - Layout(name="cores", ratio=1), - ) - - layout["middle"].split_row( - Layout(name="memory", ratio=1), - Layout(name="network", ratio=1), - ) - - return layout - - def _create_header(self) -> Panel: - return Panel( - Align.center(Text("StatsMan - System Monitor", style="bold blue")), - border_style="bright_blue", - height=3 - ) - - def _create_footer(self) -> Panel: - footer_text = ( - "[bold cyan]q[/] quit │ " - "[bold cyan]p[/] pause │ " - "[bold cyan]c[/] sort CPU │ " - "[bold cyan]m[/] sort MEM" - ) - return Panel( - Align.center(Text.from_markup(footer_text)), - border_style="bright_black", - height=3 - ) - - def render(self) -> Layout: - self.layout = self._make_layout() - - self.monitor.update_history() - - self.layout["header"].update(self._create_header()) - self.layout["footer"].update(self._create_footer()) - - self.layout["top"]["gauges"].update(self._create_system_gauges()) - self.layout["top"]["cores"].update(self._create_cpu_cores()) - - self.layout["middle"]["memory"].update(self._create_memory_visual()) - self.layout["middle"]["network"].update(self._create_network_visual()) - - self.layout["processes"].update(self._create_processes_visual()) - - return self.layout - - def _create_system_gauges(self) -> Panel: - cpu = self.monitor.get_cpu_info() - mem = self.monitor.get_memory_info() - disk = self.monitor.get_disk_info() - return self.charts.create_system_gauges(cpu, mem, disk) - - def _create_cpu_cores(self) -> Panel: - cpu = self.monitor.get_cpu_info() - history = self.monitor.get_cpu_history() - spark = self.charts.create_sparkline(history, height=6) - cores = self.charts.create_cpu_core_visualization(cpu) - return Panel(Group(Text(f"CPU Usage: {cpu.percent:.1f}%"), spark, cores), - title="CPU Cores", border_style="red") - - def _create_memory_visual(self) -> Panel: - mem = self.monitor.get_memory_info() - history = self.monitor.get_memory_history() - spark = self.charts.create_sparkline(history, height=5) - breakdown = self.charts.create_memory_breakdown(mem) - return Panel(Group(Text(f"Memory: {mem.percent:.1f}%"), spark, breakdown), - title="Memory & Swap", border_style="green") - - def _create_network_visual(self) -> Panel: - net = self.monitor.get_network_info() - return self.charts.create_network_visualization(net) - - def _create_processes_visual(self) -> Panel: - height = self.console.size.height - limit = max(8, height // 3, 20) - procs = self.monitor.get_process_info(limit=limit + 5) - - if self.sort_processes_by == "memory": - procs.sort(key=lambda p: p.memory_percent, reverse=True) - - return self.charts.create_mini_process_table(procs[:limit]) - - def set_process_sort(self, sort_by: str) -> None: - if sort_by in ("cpu", "memory"): - self.sort_processes_by = sort_by \ No newline at end of file diff --git a/src/statsman/ui/layouts/__init__.py b/src/statsman/ui/layouts/__init__.py new file mode 100644 index 0000000..2adb0e5 --- /dev/null +++ b/src/statsman/ui/layouts/__init__.py @@ -0,0 +1,19 @@ +from .terminal_controller import TerminalController +from .drawing_primitives import DrawingPrimitives +from .layout_element import LayoutElement +from .horizontal_layout import HorizontalLayout +from .vertical_layout import VerticalLayout +from .grid_layout import GridLayout +from .content_size_fitter import ContentSizeFitter +from .layout_manager import LayoutManager + +__all__ = [ + 'TerminalController', + 'DrawingPrimitives', + 'LayoutElement', + 'HorizontalLayout', + 'VerticalLayout', + 'GridLayout', + 'ContentSizeFitter', + 'LayoutManager' +] \ No newline at end of file diff --git a/src/statsman/ui/layouts/content_size_fitter.py b/src/statsman/ui/layouts/content_size_fitter.py new file mode 100644 index 0000000..576601b --- /dev/null +++ b/src/statsman/ui/layouts/content_size_fitter.py @@ -0,0 +1,26 @@ +from typing import Tuple, Optional, Dict, List, Any + +from .layout_element import LayoutElement + + +class ContentSizeFitter: + + def __init__(self, fit_mode: str = "preferred"): + self.fit_mode = fit_mode + + def fit_size(self, element: LayoutElement, available_width: int, available_height: int) -> Tuple[int, int]: + min_w, min_h = element.get_min_size() + pref_w, pref_h = element.get_preferred_size() + flex_w, flex_h = element.get_flexible_size() + + if self.fit_mode == "preferred": + return (pref_w, pref_h) + elif self.fit_mode == "min": + return (min_w, min_h) + elif self.fit_mode == "flexible": + + final_w = min_w + flex_w if flex_w > 0 else pref_w + final_h = min_h + flex_h if flex_h > 0 else pref_h + return (min(final_w, available_width), min(final_h, available_height)) + else: + return (pref_w, pref_h) \ No newline at end of file diff --git a/src/statsman/ui/layouts/drawing_primitives.py b/src/statsman/ui/layouts/drawing_primitives.py new file mode 100644 index 0000000..d4d276d --- /dev/null +++ b/src/statsman/ui/layouts/drawing_primitives.py @@ -0,0 +1,226 @@ +import sys +from typing import Tuple, Optional, Dict, List, Any + +from .terminal_controller import TerminalController + + +class DrawingPrimitives: + + def __init__(self, terminal: TerminalController): + self.terminal = terminal + + def draw_box(self, x: int, y: int, width: int, height: int, + title: Optional[str] = None, border_color: str = "white"): + width, height = max(3, width), max(3, height) + + + corners = {'tl': '╭', 'tr': '╮', 'bl': '╰', 'br': '╯'} + horizontal = '─' + vertical = '│' + + + output = [] + color_code = "" + reset_code = "" + + if not self.terminal.no_color: + color_codes = { + 'black': '30', 'red': '31', 'green': '32', 'yellow': '33', + 'blue': '34', 'magenta': '35', 'cyan': '36', 'white': '37', + 'bright_black': '90', 'bright_red': '91', 'bright_green': '92', + 'bright_yellow': '93', 'bright_blue': '94', 'bright_magenta': '95', + 'bright_cyan': '96', 'bright_white': '97' + } + color_code = f"\033[{color_codes.get(border_color, '37')}m" + reset_code = "\033[0m" + + + top_border = f"{corners['tl']}{horizontal * (width - 2)}{corners['tr']}" + output.append(f"\033[{y};{x}H{color_code}{top_border}") + + + for i in range(1, height - 1): + output.append(f"\033[{y+i};{x}H{color_code}{vertical}\033[{y+i};{x+width-1}H{vertical}") + + + bottom_border = f"{corners['bl']}{horizontal * (width - 2)}{corners['br']}" + output.append(f"\033[{y+height-1};{x}H{color_code}{bottom_border}") + + + if title and width > len(title) + 4: + title_pos = x + (width - len(title)) // 2 + output.append(f"\033[{y};{title_pos-2}H{color_code}[ {title} ]") + + + if reset_code: + output.append(reset_code) + + sys.stdout.write("".join(output)) + + def draw_at(self, x: int, y: int, text: str, color: str = "white"): + if self.terminal.no_color: + sys.stdout.write(f"\033[{y};{x}H{text}") + else: + color_codes = { + 'black': '30', 'red': '31', 'green': '32', 'yellow': '33', + 'blue': '34', 'magenta': '35', 'cyan': '36', 'white': '37', + 'bright_black': '90', 'bright_red': '91', 'bright_green': '92', + 'bright_yellow': '93', 'bright_blue': '94', 'bright_magenta': '95', + 'bright_cyan': '96', 'bright_white': '97' + } + color_code = color_codes.get(color, '37') + sys.stdout.write(f"\033[{y};{x}H\033[{color_code}m{text}\033[0m") + + def draw_centered_text(self, x: int, y: int, text: str, color: str = "white", + max_width: Optional[int] = None): + if max_width and len(text) > max_width: + text = text[:max_width-3] + "..." + + centered_x = max(1, x - len(text) // 2) + self.terminal.write_at(centered_x, y, text, color) + + def draw_progress_bar(self, x: int, y: int, width: int, percentage: float, + label: Optional[str] = None, color: str = "green"): + if width < 8: + return + + filled_width = int((percentage / 100.0) * (width - 6)) + filled_width = max(0, min(width - 6, filled_width)) + + + self.terminal.move_cursor(x, y) + self.terminal.set_text_color("cyan") + sys.stdout.write("╭" + "─" * (width - 2) + "╮") + + + for i in range(1, 3): + self.terminal.move_cursor(x, y + i) + self.terminal.set_text_color("cyan") + sys.stdout.write("│") + self.terminal.move_cursor(x + width - 1, y + i) + sys.stdout.write("│") + + + self.terminal.move_cursor(x + 1, y + i) + self.terminal.set_text_color("white") + sys.stdout.write("░" * (width - 2)) + + + if filled_width > 0: + for i in range(1, 3): + self.terminal.move_cursor(x + 1, y + i) + self.terminal.set_text_color(color) + sys.stdout.write("█" * filled_width) + + + self.terminal.move_cursor(x, y + 2) + self.terminal.set_text_color("cyan") + sys.stdout.write("╰" + "─" * (width - 2) + "╯") + + + if label: + label_text = f"{label}: {percentage:.1f}%" + self.terminal.write_at(x, y + 3, label_text, color) + else: + percent_text = f"{percentage:.1f}%" + percent_x = x + (width - len(percent_text)) // 2 + self.terminal.write_at(percent_x, y + 3, percent_text, color) + + self.terminal.reset_text_color() + + def draw_vertical_bars(self, x: int, y: int, width: int, height: int, + data: Optional[Dict[str, float]], color: str = "blue"): + if not data: + return + + num_bars = len(data) + if num_bars == 0: + return + + max_val = max(data.values()) if data.values() else 1 + if max_val == 0: + max_val = 1 + + max_val = max(max_val, 1) + + + bar_width = max(1, width // num_bars) + spacing = max(0, (width - bar_width * num_bars) // max(1, num_bars - 1)) + + + chart_height = height - 2 + + + for clear_y in range(y, y + height): + for clear_x in range(x, x + width): + self.terminal.move_cursor(clear_x, clear_y) + sys.stdout.write(" ") + + for i, (label, value) in enumerate(data.items()): + + bar_x = x + i * (bar_width + spacing) + bar_height = int((value / max_val) * chart_height) + + + bar_height = max(0, min(bar_height, chart_height)) + + if value > 0 and bar_height == 0: + bar_height = 1 + + for j in range(bar_height): + bar_y = y + height - 2 - j + if bar_y >= y and bar_y < y + height: + self.terminal.move_cursor(bar_x, bar_y) + self.terminal.set_text_color(color) + sys.stdout.write("█") + + + label_y = y + height - 1 + if label_y >= y and label_y < y + height: + self.terminal.move_cursor(bar_x, label_y) + self.terminal.set_text_color("white") + + display_label = label + if len(display_label) > bar_width: + display_label = display_label[:bar_width] + elif len(display_label) < bar_width: + + padding = (bar_width - len(display_label)) // 2 + display_label = " " * padding + display_label + display_label = display_label.ljust(bar_width) + sys.stdout.write(display_label) + + self.terminal.reset_text_color() + + def draw_sparkline(self, x: int, y: int, width: int, height: int, + data: Optional[List[float]], color: str = "cyan"): + if not data: + return + + + + recent_data = data[-width:] if len(data) > width else data + + min_val = min(recent_data) + max_val = max(recent_data) + range_val = max_val - min_val if max_val != min_val else 1 + + + + if height <= 2: + spark_chars = [' ', '█'] + elif height <= 4: + spark_chars = [' ', '▄', '█'] + else: + spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] + + + self.terminal.move_cursor(x, y) + self.terminal.set_text_color(color) + + for value in recent_data: + normalized = (value - min_val) / range_val + char_index = min(int(normalized * len(spark_chars)), len(spark_chars) - 1) + sys.stdout.write(spark_chars[max(0, char_index)]) + + self.terminal.reset_text_color() \ No newline at end of file diff --git a/src/statsman/ui/layouts/grid_layout.py b/src/statsman/ui/layouts/grid_layout.py new file mode 100644 index 0000000..6cb4ff3 --- /dev/null +++ b/src/statsman/ui/layouts/grid_layout.py @@ -0,0 +1,82 @@ +from typing import Tuple, Optional, Dict, List, Any + +from .layout_element import LayoutElement + + +class GridLayout: + + def __init__(self, rows: int = 1, cols: int = 1, spacing: int = 1, padding: int = 0, + justification: str = "start", anchor: str = "top-left"): + self.rows = rows + self.cols = cols + self.spacing = spacing + self.padding = padding + self.justification = justification + self.anchor = anchor + self.children: List[Tuple[LayoutElement, int, int, int, int]] = [] + + def add_child(self, element: LayoutElement): + self.children.append((element, 0, 0, 0, 0)) + + def calculate_layout(self, x: int, y: int, width: int, height: int): + if not self.children: + return + + + available_width = width - 2 * self.padding + available_height = height - 2 * self.padding + + + cell_width = (available_width - self.spacing * (self.cols - 1)) // self.cols + cell_height = (available_height - self.spacing * (self.rows - 1)) // self.rows + + + for i, (element, _, _, _, _) in enumerate(self.children): + if i >= self.rows * self.cols: + break + + row = i // self.cols + col = i % self.cols + + cell_x = x + self.padding + col * (cell_width + self.spacing) + cell_y = y + self.padding + row * (cell_height + self.spacing) + + + min_w, min_h = element.get_min_size() + pref_w, pref_h = element.get_preferred_size() + + + final_width = min(cell_width, max(min_w, pref_w)) + final_height = min(cell_height, max(min_h, pref_h)) + + + child_x, child_y = self._calculate_anchor_position( + cell_x, cell_y, final_width, final_height, + cell_width, cell_height, element.anchor + ) + + + self.children[i] = (element, child_x, child_y, final_width, final_height) + + def _calculate_anchor_position(self, cell_x: int, cell_y: int, w: int, h: int, + cell_w: int, cell_h: int, anchor: str) -> Tuple[int, int]: + if anchor == "top-left": + return cell_x, cell_y + elif anchor == "top-center": + return cell_x + (cell_w - w) // 2, cell_y + elif anchor == "top-right": + return cell_x + cell_w - w, cell_y + elif anchor == "center-left": + return cell_x, cell_y + (cell_h - h) // 2 + elif anchor == "center": + return cell_x + (cell_w - w) // 2, cell_y + (cell_h - h) // 2 + elif anchor == "center-right": + return cell_x + cell_w - w, cell_y + (cell_h - h) // 2 + elif anchor == "bottom-left": + return cell_x, cell_y + cell_h - h + elif anchor == "bottom-center": + return cell_x + (cell_w - w) // 2, cell_y + cell_h - h + elif anchor == "bottom-right": + return cell_x + cell_w - w, cell_y + cell_h - h + else: + return cell_x, cell_y \ No newline at end of file diff --git a/src/statsman/ui/layouts/horizontal_layout.py b/src/statsman/ui/layouts/horizontal_layout.py new file mode 100644 index 0000000..7da710e --- /dev/null +++ b/src/statsman/ui/layouts/horizontal_layout.py @@ -0,0 +1,134 @@ +from typing import Tuple, Optional, Dict, List, Any + +from .layout_element import LayoutElement + + +class HorizontalLayout: + + def __init__(self, spacing: int = 1, padding: int = 0, justification: str = "start"): + self.spacing = spacing + self.padding = padding + self.justification = justification + self.children: List[Tuple[LayoutElement, int, int, int, int]] = [] + + def add_child(self, element: LayoutElement): + self.children.append((element, 0, 0, 0, 0)) + + def calculate_layout(self, x: int, y: int, width: int, height: int): + if not self.children: + return + + + available_width = width - 2 * self.padding + available_height = height - 2 * self.padding + + + total_preferred_width = 0 + total_flexible_width = 0 + min_heights = [] + + for element, _, _, _, _ in self.children: + pref_w, pref_h = element.get_preferred_size() + flex_w, _ = element.get_flexible_size() + min_w, min_h = element.get_min_size() + + total_preferred_width += pref_w + total_flexible_width += flex_w + min_heights.append(min_h) + + + total_spacing = self.spacing * (len(self.children) - 1) + total_preferred_width += total_spacing + + + remaining_width = available_width - total_preferred_width + extra_width_per_flexible = remaining_width / total_flexible_width if total_flexible_width > 0 else 0 + + current_x = x + self.padding + max_height = min(available_height, max(min_heights) if min_heights else available_height) + + + spacing = self.spacing + if self.justification == "start": + current_x = x + self.padding + elif self.justification == "center": + total_width = sum(w for _, _, _, w, _ in self.children) + total_spacing + current_x = x + self.padding + (available_width - total_width) // 2 + elif self.justification == "end": + total_width = sum(w for _, _, _, w, _ in self.children) + total_spacing + current_x = x + width - self.padding - total_width + elif self.justification == "space-between": + if len(self.children) > 1: + total_width = sum(w for _, _, _, w, _ in self.children) + spacing = (available_width - total_width) // (len(self.children) - 1) + current_x = x + self.padding + else: + current_x = x + self.padding + elif self.justification == "space-around": + if len(self.children) > 0: + total_width = sum(w for _, _, _, w, _ in self.children) + total_spacing = (available_width - total_width) // len(self.children) + spacing = total_spacing + current_x = x + self.padding + total_spacing // 2 + else: + current_x = x + self.padding + elif self.justification == "space-evenly": + if len(self.children) > 0: + total_width = sum(w for _, _, _, w, _ in self.children) + total_spacing = (available_width - total_width) // (len(self.children) + 1) + spacing = total_spacing + current_x = x + self.padding + total_spacing + else: + current_x = x + self.padding + else: + current_x = x + self.padding + + + for i, (element, _, _, _, _) in enumerate(self.children): + pref_w, pref_h = element.get_preferred_size() + flex_w, flex_h = element.get_flexible_size() + min_w, min_h = element.get_min_size() + + + final_width = pref_w + if flex_w > 0 and extra_width_per_flexible > 0: + final_width += int(flex_w * extra_width_per_flexible) + final_width = max(min_w, min(final_width, available_width)) + + + final_height = min(max_height, max(min_h, pref_h)) + + + child_x, child_y = self._calculate_anchor_position( + current_x, y + self.padding, final_width, final_height, + available_height, element.anchor + ) + + + self.children[i] = (element, child_x, child_y, final_width, final_height) + + + current_x += final_width + spacing + + def _calculate_anchor_position(self, x: int, y: int, w: int, h: int, + container_height: int, anchor: str) -> Tuple[int, int]: + if anchor == "top-left": + return x, y + elif anchor == "top-center": + return x, y + elif anchor == "top-right": + return x, y + elif anchor == "center-left": + return x, y + (container_height - h) // 2 + elif anchor == "center": + return x, y + (container_height - h) // 2 + elif anchor == "center-right": + return x, y + (container_height - h) // 2 + elif anchor == "bottom-left": + return x, y + container_height - h + elif anchor == "bottom-center": + return x, y + container_height - h + elif anchor == "bottom-right": + return x, y + container_height - h + else: + return x, y \ No newline at end of file diff --git a/src/statsman/ui/layouts/layout_element.py b/src/statsman/ui/layouts/layout_element.py new file mode 100644 index 0000000..a936025 --- /dev/null +++ b/src/statsman/ui/layouts/layout_element.py @@ -0,0 +1,25 @@ +from typing import Tuple, Optional, Dict, List, Any + + +class LayoutElement: + + def __init__(self, min_width: int = 0, min_height: int = 0, preferred_width: int = 0, + preferred_height: int = 0, flexible_width: int = 0, flexible_height: int = 0, + anchor: str = "top-left", justification: str = "start"): + self.min_width = min_width + self.min_height = min_height + self.preferred_width = preferred_width + self.preferred_height = preferred_height + self.flexible_width = flexible_width + self.flexible_height = flexible_height + self.anchor = anchor + self.justification = justification + + def get_preferred_size(self) -> Tuple[int, int]: + return (self.preferred_width, self.preferred_height) + + def get_min_size(self) -> Tuple[int, int]: + return (self.min_width, self.min_height) + + def get_flexible_size(self) -> Tuple[int, int]: + return (self.flexible_width, self.flexible_height) \ No newline at end of file diff --git a/src/statsman/ui/layouts/layout_manager.py b/src/statsman/ui/layouts/layout_manager.py new file mode 100644 index 0000000..5df09a7 --- /dev/null +++ b/src/statsman/ui/layouts/layout_manager.py @@ -0,0 +1,52 @@ +from typing import Tuple, Optional, Dict, List, Any + +from .terminal_controller import TerminalController +from .horizontal_layout import HorizontalLayout +from .vertical_layout import VerticalLayout +from .grid_layout import GridLayout +from .content_size_fitter import ContentSizeFitter + + +class LayoutManager: + + def __init__(self, terminal: TerminalController): + self.terminal = terminal + self.width, self.height = terminal.get_size() + self.layouts: Dict[str, Any] = {} + self.size_fitter = ContentSizeFitter() + + def create_horizontal_layout(self, name: str, spacing: int = 1, padding: int = 0, + justification: str = "start") -> HorizontalLayout: + layout = HorizontalLayout(spacing, padding, justification) + self.layouts[name] = layout + return layout + + def create_vertical_layout(self, name: str, spacing: int = 1, padding: int = 0, + justification: str = "start") -> VerticalLayout: + layout = VerticalLayout(spacing, padding, justification) + self.layouts[name] = layout + return layout + + def create_grid_layout(self, name: str, rows: int = 1, cols: int = 1, + spacing: int = 1, padding: int = 0) -> GridLayout: + layout = GridLayout(rows, cols, spacing, padding) + self.layouts[name] = layout + return layout + + def calculate_layout(self, name: str, x: int, y: int, width: int, height: int): + if name in self.layouts: + self.layouts[name].calculate_layout(x, y, width, height) + + def get_child_bounds(self, layout_name: str, child_index: int) -> Tuple[int, int, int, int]: + if layout_name in self.layouts: + layout = self.layouts[layout_name] + if child_index < len(layout.children): + _, x, y, w, h = layout.children[child_index] + return (x, y, w, h) + return (0, 0, 0, 0) + + def update_size(self): + self.width, self.height = self.terminal.get_size() + + def get_size(self) -> Tuple[int, int]: + return (self.width, self.height) \ No newline at end of file diff --git a/src/statsman/ui/layouts/terminal_controller.py b/src/statsman/ui/layouts/terminal_controller.py new file mode 100644 index 0000000..1e7a04e --- /dev/null +++ b/src/statsman/ui/layouts/terminal_controller.py @@ -0,0 +1,116 @@ +import sys +import os +import signal +from typing import Tuple, Optional, Dict, List, Any + + +class TerminalController: + + def __init__(self, no_color: bool = False): + self.no_color = no_color + self.original_settings = None + self.size = (80, 24) + + def initialize(self, bg_color: str = "black"): + if not self.no_color: + + sys.stdout.write("\033[?1049h") + + sys.stdout.write("\033[?25l") + + sys.stdout.write("\033[2J") + + sys.stdout.write("\033[H") + + self._set_background(bg_color) + + self._enable_raw_mode() + + sys.stdout.flush() + self._update_size() + + def cleanup(self): + + if self.original_settings: + try: + import termios + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.original_settings) + except: + pass + + + sys.stdout.write("\033[?25h") + + sys.stdout.write("\033[?1049l") + + sys.stdout.write("\033]111\007") + sys.stdout.flush() + + def _enable_raw_mode(self): + try: + import termios + import tty + if sys.stdin.isatty(): + self.original_settings = termios.tcgetattr(sys.stdin) + tty.setraw(sys.stdin.fileno()) + except: + pass + + def _set_background(self, color: str): + if self.no_color: + return + + color_map = { + 'black': '#000000', 'blue': '#000080', 'dark_blue': '#00008B', + 'purple': '#800080', 'cyan': '#008080', 'green': '#006400', + 'red': '#8B0000', 'white': '#FFFFFF', 'yellow': '#FFFF00', + 'magenta': '#FF00FF', + } + + hex_color = color_map.get(color.lower(), '#000000') + sys.stdout.write(f"\033]11;{hex_color}\007") + + def _update_size(self): + try: + import shutil + self.size = shutil.get_terminal_size() + except: + self.size = ( + int(os.environ.get('COLUMNS', 80)), + int(os.environ.get('LINES', 24)) + ) + + def get_size(self) -> Tuple[int, int]: + self._update_size() + return self.size + + def clear(self): + + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + def move_cursor(self, x: int, y: int): + sys.stdout.write(f"\033[{y};{x}H") + + def set_text_color(self, color: str): + if self.no_color: + return + color_codes = { + 'black': '30', 'red': '31', 'green': '32', 'yellow': '33', + 'blue': '34', 'magenta': '35', 'cyan': '36', 'white': '37', + 'bright_black': '90', 'bright_red': '91', 'bright_green': '92', + 'bright_yellow': '93', 'bright_blue': '94', 'bright_magenta': '95', + 'bright_cyan': '96', 'bright_white': '97' + } + code = color_codes.get(color, '37') + sys.stdout.write(f"\033[{code}m") + + def reset_text_color(self): + if not self.no_color: + sys.stdout.write("\033[0m") + + def write_at(self, x: int, y: int, text: str, color: str = "white"): + self.move_cursor(x, y) + self.set_text_color(color) + sys.stdout.write(text) + self.reset_text_color() \ No newline at end of file diff --git a/src/statsman/ui/layouts/vertical_layout.py b/src/statsman/ui/layouts/vertical_layout.py new file mode 100644 index 0000000..f703628 --- /dev/null +++ b/src/statsman/ui/layouts/vertical_layout.py @@ -0,0 +1,134 @@ +from typing import Tuple, Optional, Dict, List, Any + +from .layout_element import LayoutElement + + +class VerticalLayout: + + def __init__(self, spacing: int = 1, padding: int = 0, justification: str = "start"): + self.spacing = spacing + self.padding = padding + self.justification = justification + self.children: List[Tuple[LayoutElement, int, int, int, int]] = [] + + def add_child(self, element: LayoutElement): + self.children.append((element, 0, 0, 0, 0)) + + def calculate_layout(self, x: int, y: int, width: int, height: int): + if not self.children: + return + + + available_width = width - 2 * self.padding + available_height = height - 2 * self.padding + + + total_preferred_height = 0 + total_flexible_height = 0 + min_widths = [] + + for element, _, _, _, _ in self.children: + pref_w, pref_h = element.get_preferred_size() + _, flex_h = element.get_flexible_size() + min_w, min_h = element.get_min_size() + + total_preferred_height += pref_h + total_flexible_height += flex_h + min_widths.append(min_w) + + + total_spacing = self.spacing * (len(self.children) - 1) + total_preferred_height += total_spacing + + + remaining_height = available_height - total_preferred_height + extra_height_per_flexible = remaining_height / total_flexible_height if total_flexible_height > 0 else 0 + + current_y = y + self.padding + max_width = min(available_width, max(min_widths) if min_widths else available_width) + + + spacing = self.spacing + if self.justification == "start": + current_y = y + self.padding + elif self.justification == "center": + total_height = sum(h for _, _, _, _, h in self.children) + total_spacing + current_y = y + self.padding + (available_height - total_height) // 2 + elif self.justification == "end": + total_height = sum(h for _, _, _, _, h in self.children) + total_spacing + current_y = y + height - self.padding - total_height + elif self.justification == "space-between": + if len(self.children) > 1: + total_height = sum(h for _, _, _, _, h in self.children) + spacing = (available_height - total_height) // (len(self.children) - 1) + current_y = y + self.padding + else: + current_y = y + self.padding + elif self.justification == "space-around": + if len(self.children) > 0: + total_height = sum(h for _, _, _, _, h in self.children) + total_spacing = (available_height - total_height) // len(self.children) + spacing = total_spacing + current_y = y + self.padding + total_spacing // 2 + else: + current_y = y + self.padding + elif self.justification == "space-evenly": + if len(self.children) > 0: + total_height = sum(h for _, _, _, _, h in self.children) + total_spacing = (available_height - total_height) // (len(self.children) + 1) + spacing = total_spacing + current_y = y + self.padding + total_spacing + else: + current_y = y + self.padding + else: + current_y = y + self.padding + + + for i, (element, _, _, _, _) in enumerate(self.children): + pref_w, pref_h = element.get_preferred_size() + flex_w, flex_h = element.get_flexible_size() + min_w, min_h = element.get_min_size() + + + final_height = pref_h + if flex_h > 0 and extra_height_per_flexible > 0: + final_height += int(flex_h * extra_height_per_flexible) + final_height = max(min_h, min(final_height, available_height)) + + + final_width = min(max_width, max(min_w, pref_w)) + + + child_x, child_y = self._calculate_anchor_position( + x + self.padding, current_y, final_width, final_height, + available_width, element.anchor + ) + + + self.children[i] = (element, child_x, child_y, final_width, final_height) + + + current_y += final_height + spacing + + def _calculate_anchor_position(self, x: int, y: int, w: int, h: int, + container_width: int, anchor: str) -> Tuple[int, int]: + if anchor == "top-left": + return x, y + elif anchor == "top-center": + return x + (container_width - w) // 2, y + elif anchor == "top-right": + return x + container_width - w, y + elif anchor == "center-left": + return x, y + elif anchor == "center": + return x + (container_width - w) // 2, y + elif anchor == "center-right": + return x + container_width - w, y + elif anchor == "bottom-left": + return x, y + elif anchor == "bottom-center": + return x + (container_width - w) // 2, y + elif anchor == "bottom-right": + return x + container_width - w, y + else: + return x, y \ No newline at end of file diff --git a/src/statsman/ui/renderer.py b/src/statsman/ui/renderer.py new file mode 100644 index 0000000..141bca1 --- /dev/null +++ b/src/statsman/ui/renderer.py @@ -0,0 +1,253 @@ +import sys +import time +import signal +import threading +from typing import Optional +import threading + +from .layouts import (TerminalController, LayoutManager, DrawingPrimitives, + HorizontalLayout, VerticalLayout, GridLayout, ContentSizeFitter) +from .components import SystemOverview, CPUCores, MemoryDisplay, NetworkDisplay, ProcessList, HeaderFooter + + +class StatsManRenderer: + + def __init__(self, no_color: bool = False, bg_color: str = "black"): + self.terminal = TerminalController(no_color) + self.layout_manager = LayoutManager(self.terminal) + self.bg_color = bg_color + self.sort_processes_by = "cpu" + self.running = False + self._needs_full_redraw = False + + self.drawing = DrawingPrimitives(self.terminal) + + + self.system_overview = SystemOverview(self.terminal, self.drawing) + self.cpu_cores = CPUCores(self.terminal, self.drawing) + self.memory_display = MemoryDisplay(self.terminal, self.drawing) + self.network_display = NetworkDisplay(self.terminal, self.drawing) + self.process_list = ProcessList(self.terminal, self.drawing) + self.header_footer = HeaderFooter(self.terminal, self.drawing) + + + + + try: + from ..system_monitor import SystemMonitor + self.monitor = SystemMonitor(history_size=120) + except: + self.monitor = None + + def initialize(self): + self.terminal.initialize(self.bg_color) + + import time + time.sleep(0.01) + self.layout_manager.update_size() + + width, height = self.layout_manager.get_size() + self._last_width, self._last_height = width, height + self.running = True + + def cleanup(self): + self.running = False + self.terminal.cleanup() + + def render(self): + + self.layout_manager.update_size() + width, height = self.layout_manager.get_size() + + + if not hasattr(self, '_last_width'): + self._last_width, self._last_height = width, height + self._needs_full_redraw = False + + + size_changed = (width != self._last_width or height != self._last_height) + + if size_changed: + + self._last_width, self._last_height = width, height + + self._needs_full_redraw = True + elif self._needs_full_redraw: + + pass + + + if self._needs_full_redraw or not hasattr(self, '_rendered_once'): + self.terminal.clear() + + time.sleep(0.01) + self._rendered_once = True + self._needs_full_redraw = False + + + if width < 70 or height < 20: + + self.drawing.draw_centered_text(width // 2, height // 2, + "Terminal too small. Please resize to at least 70x20.", + "red") + sys.stdout.flush() + return + + + aspect_ratio = width / height + if aspect_ratio < 1.5 or aspect_ratio > 4.0: + + self.drawing.draw_centered_text(width // 2, height // 2, + "Terminal aspect ratio too extreme. Please resize for better proportions.", + "red") + sys.stdout.flush() + return + + + if self.monitor: + self.monitor.update_history() + + + header_h, footer_h = 3, 3 + content_h = height - header_h - footer_h - 1 + + + self.header_footer.render_header(1, 1, width, header_h) + + + content_y = header_h + 1 + + + section_padding = 1 + + + if height < 20: + + grid_rows = 1 + grid_cols = 2 + show_processes = True + panels = ["system", "cpu"] + else: + + grid_rows = 2 + grid_cols = 2 + show_processes = True + panels = ["system", "cpu", "memory", "network"] + + + grid_width = width - 2 + grid_height = content_h - section_padding + + if show_processes: + + processes_height = max(5, grid_height // 5) + grid_height = grid_height - processes_height - section_padding + + + total_spacing_width = (grid_cols - 1) * section_padding + total_spacing_height = (grid_rows - 1) * section_padding + cell_width = (grid_width - total_spacing_width) // grid_cols + cell_height = (grid_height - total_spacing_height) // grid_rows + + + + + panel_grid = { + "system": (0, 0), + "cpu": (0, 1), + "memory": (1, 0), + "network": (1, 1) + } + + + if self.monitor: + for panel_name in panels: + if panel_name in panel_grid: + row, col = panel_grid[panel_name] + + + panel_x = 1 + col * (cell_width + section_padding) + panel_y = content_y + section_padding + row * (cell_height + section_padding) + panel_w = cell_width + panel_h = cell_height + + + if panel_y + panel_h > height - footer_h: + panel_h = max(5, height - footer_h - panel_y) + + + if panel_name == "system": + cpu_info = self.monitor.get_cpu_info() + mem_info = self.monitor.get_memory_info() + disk_info = self.monitor.get_disk_info() + self.system_overview.render(panel_x, panel_y, panel_w, panel_h, cpu_info, mem_info, disk_info) + elif panel_name == "cpu": + cpu_info = self.monitor.get_cpu_info() + self.cpu_cores.render(panel_x, panel_y, panel_w, panel_h, cpu_info) + elif panel_name == "memory": + mem_info = self.monitor.get_memory_info() + self.memory_display.render(panel_x, panel_y, panel_w, panel_h, mem_info) + elif panel_name == "network": + net_info = self.monitor.get_network_info() + self.network_display.render(panel_x, panel_y, panel_w, panel_h, net_info) + + + if show_processes and self.monitor: + + + grid_end_y = content_y + section_padding + (grid_rows * (cell_height + section_padding)) - section_padding + proc_y = grid_end_y + section_padding + proc_h = max(6, height - proc_y - footer_h - 1) + processes = self.monitor.get_process_info(limit=min(20, proc_h - 2)) + self.process_list.set_sort_method(self.sort_processes_by) + self.process_list.render(1, proc_y, width, proc_h, processes) + + + + + separator_y = height - footer_h + if separator_y >= 1: + self.drawing.draw_at(1, separator_y, "═" * width, "cyan") + + + footer_y = height - footer_h + 1 + if footer_y >= 1: + + sep_y = footer_y - 1 + if sep_y >= 1: + self.drawing.draw_at(1, sep_y, "─" * width, "blue") + + self.header_footer.render_footer(1, footer_y, width, footer_h) + + + sys.stdout.flush() + + def _handle_resize(self): + + self.layout_manager.update_size() + new_width, new_height = self.layout_manager.get_size() + + + if new_width != getattr(self, '_last_width', 0) or new_height != getattr(self, '_last_height', 0): + + self._needs_full_redraw = True + + self._last_width, self._last_height = new_width, new_height + + + + def set_process_sort(self, sort_by: str): + if sort_by in ("cpu", "memory"): + self.sort_processes_by = sort_by + + def handle_keyboard_input(self, key_handler): + try: + import select + while self.running: + if select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []): + char = sys.stdin.read(1) + if not key_handler(char): + break + time.sleep(0.01) + except (ImportError, OSError, KeyboardInterrupt): + pass \ No newline at end of file