4 Commits
0.1.2 ... 0.1.3

Author SHA1 Message Date
Exil Productions
d6f1b565d8 change version 2025-12-04 23:15:42 +01:00
Exil Productions
d00016f2eb cleaned file 2025-12-04 22:54:16 +01:00
Exil Productions
0c91fe6923 Updated UI to be Custom Renderd 2025-12-04 22:47:20 +01:00
Exil Productions
2820893b4f Fix display error 2025-12-04 15:59:47 +01:00
25 changed files with 1560 additions and 404 deletions

View File

@@ -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",
]

View File

@@ -1,3 +1,3 @@
__version__ = "0.1.2"
__version__ = "0.1.3"
__author__ = "ExilProductions"
__email__ = "exil.productions.business@gmail.com"

View File

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

View File

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

View File

@@ -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)
return list(self.network_history)

75
src/statsman/ui/app.py Normal file
View 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

View File

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

View 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'
]

View 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")

View 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")

View 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")

View 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"

View 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

View 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")

View File

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

View 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'
]

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

View 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()

View 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

View 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

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

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

View 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()

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