Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f1b565d8 | ||
|
|
d00016f2eb | ||
|
|
0c91fe6923 | ||
|
|
2820893b4f |
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "statsman"
|
||||
version = "0.1.2"
|
||||
description = "A real-time terminal-based system monitoring tool with ASCII visualizations"
|
||||
version = "0.1.3"
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.1.3"
|
||||
__author__ = "ExilProductions"
|
||||
__email__ = "exil.productions.business@gmail.com"
|
||||
@@ -1,102 +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):
|
||||
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' in locals():
|
||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
||||
except:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
|
||||
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")
|
||||
@@ -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.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)
|
||||
@click.option(
|
||||
"--bg-color",
|
||||
default="black",
|
||||
help="Terminal background color (default: black)",
|
||||
)
|
||||
@click.version_option(version="0.1.3", prog_name="statsman")
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
75
src/statsman/ui/app.py
Normal file
75
src/statsman/ui/app.py
Normal file
@@ -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
|
||||
@@ -1,157 +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) -> Panel:
|
||||
if not processes:
|
||||
return Panel("No processes", border_style="red")
|
||||
|
||||
sorted_processes = sorted(processes, key=lambda p: p.cpu_percent, reverse=True)
|
||||
|
||||
lines = ["PID Name CPU MEM ", "=" * 55]
|
||||
|
||||
for proc in sorted_processes[:limit]:
|
||||
cpu_bar = self._create_mini_bar(proc.cpu_percent, 10)
|
||||
mem_bar = self._create_mini_bar(proc.memory_percent, 10)
|
||||
|
||||
name = proc.name[:14] + ".." if len(proc.name) > 16 else proc.name.ljust(16)
|
||||
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) -> Panel:
|
||||
gauges = []
|
||||
|
||||
cpu_gauge = self._create_gauge(cpu_info.percent, "CPU")
|
||||
gauges.append(cpu_gauge)
|
||||
|
||||
mem_gauge = self._create_gauge(memory_info.percent, "MEM")
|
||||
gauges.append(mem_gauge)
|
||||
|
||||
disk_gauge = self._create_gauge(disk_info.percent, "DSK")
|
||||
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) -> 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),
|
||||
}
|
||||
|
||||
return self.create_horizontal_bars(network_data)
|
||||
|
||||
def create_cpu_core_visualization(self, cpu_info: Any) -> 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
|
||||
|
||||
return self.create_vertical_bars(core_data, height=8, width=40)
|
||||
|
||||
def create_memory_breakdown(self, memory_info: Any) -> 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,
|
||||
}
|
||||
|
||||
return self.create_horizontal_bars(memory_data)
|
||||
|
||||
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"
|
||||
15
src/statsman/ui/components/__init__.py
Normal file
15
src/statsman/ui/components/__init__.py
Normal file
@@ -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'
|
||||
]
|
||||
54
src/statsman/ui/components/cpu_cores.py
Normal file
54
src/statsman/ui/components/cpu_cores.py
Normal file
@@ -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")
|
||||
43
src/statsman/ui/components/header_footer.py
Normal file
43
src/statsman/ui/components/header_footer.py
Normal file
@@ -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")
|
||||
66
src/statsman/ui/components/memory_display.py
Normal file
66
src/statsman/ui/components/memory_display.py
Normal file
@@ -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")
|
||||
87
src/statsman/ui/components/network_display.py
Normal file
87
src/statsman/ui/components/network_display.py
Normal file
@@ -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"
|
||||
86
src/statsman/ui/components/process_list.py
Normal file
86
src/statsman/ui/components/process_list.py
Normal file
@@ -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
|
||||
51
src/statsman/ui/components/system_overview.py
Normal file
51
src/statsman/ui/components/system_overview.py
Normal file
@@ -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")
|
||||
@@ -1,133 +0,0 @@
|
||||
from rich.console import Console, Group
|
||||
from rich.layout import Layout
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
from rich.align import Align
|
||||
from typing import Optional
|
||||
import time
|
||||
|
||||
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()
|
||||
self.charts = ChartRenderer(self.console)
|
||||
self.layout = Layout()
|
||||
self.sort_processes_by = "cpu"
|
||||
|
||||
self._setup_visual_layout()
|
||||
|
||||
def _setup_visual_layout(self) -> None:
|
||||
self.layout.split(
|
||||
Layout(name="header", size=3),
|
||||
Layout(name="main"),
|
||||
Layout(name="footer", size=3),
|
||||
)
|
||||
|
||||
self.layout["main"].split_column(
|
||||
Layout(name="top", size=16),
|
||||
Layout(name="middle", size=14),
|
||||
Layout(name="bottom", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["top"].split_row(
|
||||
Layout(name="gauges", ratio=1),
|
||||
Layout(name="cores", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["middle"].split_row(
|
||||
Layout(name="memory", ratio=1),
|
||||
Layout(name="network", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["bottom"].split_row(
|
||||
Layout(name="processes"),
|
||||
)
|
||||
|
||||
def _create_header(self) -> Panel:
|
||||
header_text = Text.from_markup(
|
||||
"[bold blue]StatsMan[/bold blue] - System Monitor",
|
||||
justify="center"
|
||||
)
|
||||
return Panel(
|
||||
Align.center(header_text),
|
||||
border_style="blue"
|
||||
)
|
||||
|
||||
def _create_footer(self) -> Panel:
|
||||
controls = Text.from_markup(
|
||||
"[cyan]q:quit p:pause c:cpu m:mem r:reset[/cyan]",
|
||||
justify="center"
|
||||
)
|
||||
return Panel(
|
||||
Align.center(controls),
|
||||
border_style="cyan"
|
||||
)
|
||||
|
||||
def _create_system_gauges(self) -> Panel:
|
||||
cpu_info = self.monitor.get_cpu_info()
|
||||
memory_info = self.monitor.get_memory_info()
|
||||
disk_info = self.monitor.get_disk_info()
|
||||
|
||||
return self.charts.create_system_gauges(cpu_info, memory_info, disk_info)
|
||||
|
||||
def _create_cpu_cores(self) -> Panel:
|
||||
cpu_info = self.monitor.get_cpu_info()
|
||||
cpu_history = self.monitor.get_cpu_history()
|
||||
|
||||
sparkline = self.charts.create_sparkline(cpu_history, width=60, height=8)
|
||||
sparkline_text = Text.from_markup(f"[cyan]CPU History:[/cyan] {sparkline}")
|
||||
|
||||
cores_panel = self.charts.create_cpu_core_visualization(cpu_info)
|
||||
|
||||
return Panel(
|
||||
Group(sparkline_text, cores_panel),
|
||||
title=f"CPU: {cpu_info.percent:.1f}%",
|
||||
border_style="red"
|
||||
)
|
||||
|
||||
def _create_memory_visual(self) -> Panel:
|
||||
memory_info = self.monitor.get_memory_info()
|
||||
memory_history = self.monitor.get_memory_history()
|
||||
|
||||
sparkline = self.charts.create_sparkline(memory_history, width=50, height=6)
|
||||
sparkline_text = Text.from_markup(f"[green]Memory History:[/green] {sparkline}")
|
||||
|
||||
breakdown_panel = self.charts.create_memory_breakdown(memory_info)
|
||||
|
||||
return Panel(
|
||||
Group(sparkline_text, breakdown_panel),
|
||||
title=f"Memory: {memory_info.percent:.1f}%",
|
||||
border_style="green"
|
||||
)
|
||||
|
||||
def _create_network_visual(self) -> Panel:
|
||||
network_info = self.monitor.get_network_info()
|
||||
return self.charts.create_network_visualization(network_info)
|
||||
|
||||
def _create_processes_visual(self) -> Panel:
|
||||
processes = self.monitor.get_process_info(limit=20)
|
||||
return self.charts.create_mini_process_table(processes, limit=16)
|
||||
|
||||
def update_layout(self) -> None:
|
||||
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["bottom"]["processes"].update(self._create_processes_visual())
|
||||
|
||||
def render(self) -> Layout:
|
||||
self.monitor.update_history()
|
||||
self.update_layout()
|
||||
return self.layout
|
||||
|
||||
def set_process_sort(self, sort_by: str) -> None:
|
||||
if sort_by in ['cpu', 'memory']:
|
||||
self.sort_processes_by = sort_by
|
||||
19
src/statsman/ui/layouts/__init__.py
Normal file
19
src/statsman/ui/layouts/__init__.py
Normal file
@@ -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'
|
||||
]
|
||||
26
src/statsman/ui/layouts/content_size_fitter.py
Normal file
26
src/statsman/ui/layouts/content_size_fitter.py
Normal file
@@ -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)
|
||||
226
src/statsman/ui/layouts/drawing_primitives.py
Normal file
226
src/statsman/ui/layouts/drawing_primitives.py
Normal file
@@ -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()
|
||||
82
src/statsman/ui/layouts/grid_layout.py
Normal file
82
src/statsman/ui/layouts/grid_layout.py
Normal file
@@ -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
|
||||
134
src/statsman/ui/layouts/horizontal_layout.py
Normal file
134
src/statsman/ui/layouts/horizontal_layout.py
Normal file
@@ -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
|
||||
25
src/statsman/ui/layouts/layout_element.py
Normal file
25
src/statsman/ui/layouts/layout_element.py
Normal file
@@ -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)
|
||||
52
src/statsman/ui/layouts/layout_manager.py
Normal file
52
src/statsman/ui/layouts/layout_manager.py
Normal file
@@ -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)
|
||||
116
src/statsman/ui/layouts/terminal_controller.py
Normal file
116
src/statsman/ui/layouts/terminal_controller.py
Normal file
@@ -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()
|
||||
134
src/statsman/ui/layouts/vertical_layout.py
Normal file
134
src/statsman/ui/layouts/vertical_layout.py
Normal file
@@ -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
|
||||
253
src/statsman/ui/renderer.py
Normal file
253
src/statsman/ui/renderer.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user