Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ec644f0c | ||
|
|
d6f1b565d8 | ||
|
|
d00016f2eb | ||
|
|
0c91fe6923 | ||
|
|
2820893b4f |
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "statsman"
|
name = "statsman"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
description = "A real-time terminal-based system monitoring tool with ASCII visualizations"
|
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"}]
|
authors = [{name = "Exil Productions", email = "exil.productions.business@gmail.com"}]
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: System Administrators",
|
"Intended Audience :: System Administrators",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
@@ -27,7 +27,6 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"psutil>=5.9.0",
|
"psutil>=5.9.0",
|
||||||
"rich>=13.0.0",
|
|
||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
__version__ = "0.1.2"
|
__version__ = "0.1.3"
|
||||||
__author__ = "ExilProductions"
|
__author__ = "ExilProductions"
|
||||||
__email__ = "exil.productions.business@gmail.com"
|
__email__ = "exil.productions.business@gmail.com"
|
||||||
@@ -2,101 +2,74 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
from .ui.renderer import StatsManRenderer
|
||||||
from rich.console import Console
|
|
||||||
from rich.live import Live
|
|
||||||
|
|
||||||
from .system_monitor import SystemMonitor
|
|
||||||
from .ui.dashboard import Dashboard
|
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:
|
class StatsManApp:
|
||||||
def __init__(self, refresh_rate: float = 1.0, no_color: bool = False):
|
|
||||||
|
def __init__(self, refresh_rate: float = 1.0, no_color: bool = False, bg_color: str = "black"):
|
||||||
self.refresh_rate = refresh_rate
|
self.refresh_rate = refresh_rate
|
||||||
self.no_color = no_color
|
self.no_color = no_color
|
||||||
|
self.bg_color = bg_color
|
||||||
|
|
||||||
self.console = Console(color_system=None if no_color else "auto")
|
|
||||||
self.dashboard = Dashboard(self.console, no_color)
|
self.renderer = StatsManRenderer(no_color, bg_color)
|
||||||
self.live = None
|
self.keyboard_handler = KeyboardHandler(self.renderer)
|
||||||
self.running = False
|
|
||||||
self.paused = False
|
def run(self):
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
def _signal_handler(self, signum, frame):
|
def on_resize(sig, frame):
|
||||||
self.running = False
|
if self.renderer.running:
|
||||||
if self.live:
|
|
||||||
self.live.stop()
|
|
||||||
|
|
||||||
def _handle_keyboard_input(self):
|
self.renderer._handle_resize()
|
||||||
try:
|
|
||||||
import select
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
|
|
||||||
old_settings = termios.tcgetattr(sys.stdin)
|
signal.signal(signal.SIGWINCH, on_resize)
|
||||||
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.renderer.initialize()
|
||||||
self.running = False
|
self.keyboard_handler.start_input_thread()
|
||||||
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:
|
try:
|
||||||
with Live(
|
while self.renderer.running:
|
||||||
self.dashboard.render(),
|
self.renderer.render()
|
||||||
console=self.console,
|
time.sleep(self.refresh_rate)
|
||||||
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:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.running = False
|
self.renderer.cleanup()
|
||||||
if self.live:
|
print("\n[bold cyan]StatsMan – See you later![/]\n")
|
||||||
self.live.stop()
|
|
||||||
|
|
||||||
self.console.clear()
|
def _signal_handler(self, signum, frame):
|
||||||
self.console.print("StatsMan - Goodbye!", justify="center")
|
self.renderer.running = False
|
||||||
@@ -16,9 +16,15 @@ from .app import StatsManApp
|
|||||||
default=False,
|
default=False,
|
||||||
help="Disable colored output",
|
help="Disable colored output",
|
||||||
)
|
)
|
||||||
@click.version_option(version="0.1.0", prog_name="statsman")
|
@click.option(
|
||||||
def main(refresh_rate: float, no_color: bool) -> None:
|
"--bg-color",
|
||||||
app = StatsManApp(refresh_rate=refresh_rate, no_color=no_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()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class NetworkInfo:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessInfo:
|
class ProcessInfo:
|
||||||
"""Process information."""
|
|
||||||
pid: int
|
pid: int
|
||||||
name: str
|
name: str
|
||||||
cpu_percent: float
|
cpu_percent: float
|
||||||
@@ -77,7 +76,7 @@ class SystemMonitor:
|
|||||||
freq = psutil.cpu_freq().current if psutil.cpu_freq() else 0.0
|
freq = psutil.cpu_freq().current if psutil.cpu_freq() else 0.0
|
||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
freq = 0.0
|
freq = 0.0
|
||||||
#linux only
|
|
||||||
try:
|
try:
|
||||||
load_avg = list(psutil.getloadavg())
|
load_avg = list(psutil.getloadavg())
|
||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
|
|||||||
@@ -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