Skip to content

Commit 9aa66a0

Browse files
Merge pull request #9223 from ThomasWaldmann/cockpit
borg --cockpit: show TUI based on Textual
2 parents 2152e1e + 2af8de5 commit 9aa66a0

File tree

9 files changed

+937
-0
lines changed

9 files changed

+937
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pyfuse3 = ["pyfuse3 >= 3.1.1"]
4646
nofuse = []
4747
s3 = ["borgstore[s3] ~= 0.3.0"]
4848
sftp = ["borgstore[sftp] ~= 0.3.0"]
49+
cockpit = ["textual>=6.8.0"] # might also work with older versions, untested
4950

5051
[project.urls]
5152
"Homepage" = "https://borgbackup.org/"

src/borg/archiver/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ def build_parser(self):
337337
parser.add_argument(
338338
"-V", "--version", action="version", version="%(prog)s " + __version__, help="show version number and exit"
339339
)
340+
parser.add_argument("--cockpit", dest="cockpit", action="store_true", help="Start the Borg TUI")
340341
parser.common_options.add_common_group(parser, "_maincommand", provide_defaults=True)
341342

342343
common_parser = argparse.ArgumentParser(add_help=False, prog=self.prog)
@@ -646,6 +647,23 @@ def main(): # pragma: no cover
646647
print(msg, file=sys.stderr)
647648
print(tb, file=sys.stderr)
648649
sys.exit(EXIT_ERROR)
650+
651+
if args.cockpit:
652+
# Cockpit TUI operation
653+
try:
654+
from ..cockpit.app import BorgCockpitApp
655+
except ImportError as err:
656+
print(f"ImportError: {err}", file=sys.stderr)
657+
print("The Borg Cockpit feature has some additional requirements.", file=sys.stderr)
658+
print("Please install them using: pip install 'borgbackup[cockpit]'", file=sys.stderr)
659+
sys.exit(EXIT_ERROR)
660+
661+
app = BorgCockpitApp()
662+
app.borg_args = [arg for arg in sys.argv[1:] if arg != "--cockpit"]
663+
app.run()
664+
sys.exit(EXIT_SUCCESS) # borg subprocess RC was already shown on the TUI
665+
666+
# normal borg CLI operation
649667
try:
650668
with sig_int:
651669
exit_code = archiver.run(args)

src/borg/cockpit/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Borg Cockpit - Terminal User Interface for BorgBackup.
3+
4+
This module contains the TUI implementation using Textual.
5+
"""

src/borg/cockpit/app.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Borg Cockpit - Application Entry Point.
3+
"""
4+
5+
import asyncio
6+
import time
7+
8+
from textual.app import App, ComposeResult
9+
from textual.widgets import Header, Footer
10+
from textual.containers import Horizontal, Container
11+
12+
from .theme import theme
13+
14+
15+
class BorgCockpitApp(App):
16+
"""The main TUI Application class for Borg Cockpit."""
17+
18+
from .. import __version__ as BORG_VERSION
19+
20+
TITLE = f"Cockpit for BorgBackup {BORG_VERSION}"
21+
CSS_PATH = "cockpit.tcss"
22+
BINDINGS = [("q", "quit", "Quit"), ("ctrl+c", "quit", "Quit"), ("t", "toggle_translator", "Toggle Translator")]
23+
24+
def compose(self) -> ComposeResult:
25+
"""Create child widgets for the app."""
26+
from .widgets import LogoPanel, StatusPanel, StandardLog
27+
28+
yield Header(show_clock=True)
29+
30+
with Container(id="main-grid"):
31+
with Horizontal(id="top-row"):
32+
yield LogoPanel(id="logopanel")
33+
yield StatusPanel(id="status")
34+
35+
yield StandardLog(id="standard-log")
36+
37+
yield Footer()
38+
39+
def get_theme_variable_defaults(self):
40+
# make these variables available to ALL themes
41+
return {
42+
"pulsar-color": "#ffffff",
43+
"pulsar-dim-color": "#000000",
44+
"star-color": "#888888",
45+
"star-bright-color": "#ffffff",
46+
"logo-color": "#00dd00",
47+
}
48+
49+
def on_load(self) -> None:
50+
"""Initialize theme before UI."""
51+
self.register_theme(theme)
52+
self.theme = theme.name
53+
54+
def on_mount(self) -> None:
55+
"""Initialize components."""
56+
from .runner import BorgRunner
57+
58+
self.query_one("#logo").styles.animate("opacity", 1, duration=1)
59+
self.query_one("#slogan").styles.animate("opacity", 1, duration=1)
60+
61+
self.start_time = time.monotonic()
62+
self.process_running = True
63+
args = getattr(self, "borg_args", ["--version"]) # Default to safe command if none passed
64+
self.runner = BorgRunner(args, self.handle_log_event)
65+
self.runner_task = asyncio.create_task(self.runner.start())
66+
67+
# Speed tracking
68+
self.total_lines_processed = 0
69+
self.last_lines_processed = 0
70+
self.speed_timer = self.set_interval(1.0, self.compute_speed)
71+
72+
def compute_speed(self) -> None:
73+
"""Calculate and update speed (lines per second)."""
74+
current_lines = self.total_lines_processed
75+
lines_per_second = float(current_lines - self.last_lines_processed)
76+
self.last_lines_processed = current_lines
77+
78+
status_panel = self.query_one("#status")
79+
status_panel.update_speed(lines_per_second / 1000)
80+
if self.process_running:
81+
status_panel.elapsed_time = time.monotonic() - self.start_time
82+
83+
async def on_unmount(self) -> None:
84+
"""Cleanup resources on app shutdown."""
85+
if hasattr(self, "runner"):
86+
await self.runner.stop()
87+
88+
async def action_quit(self) -> None:
89+
"""Handle quit action."""
90+
if hasattr(self, "speed_timer"):
91+
self.speed_timer.stop()
92+
if hasattr(self, "runner"):
93+
await self.runner.stop()
94+
if hasattr(self, "runner_task"):
95+
await self.runner_task
96+
self.query_one("#logo").styles.animate("opacity", 0, duration=2)
97+
self.query_one("#slogan").styles.animate("opacity", 0, duration=2)
98+
await asyncio.sleep(2) # give the user a chance the see the borg RC
99+
self.exit()
100+
101+
def action_toggle_translator(self) -> None:
102+
"""Toggle the universal translator."""
103+
from .translator import TRANSLATOR
104+
105+
TRANSLATOR.toggle()
106+
# Refresh dynamic UI elements
107+
self.query_one("#status").refresh_ui_labels()
108+
self.query_one("#standard-log").update_title()
109+
self.query_one("#slogan").update_slogan()
110+
111+
def handle_log_event(self, data: dict):
112+
"""Process a event from BorgRunner."""
113+
msg_type = data.get("type", "log")
114+
115+
if msg_type == "stream_line":
116+
self.total_lines_processed += 1
117+
line = data.get("line", "")
118+
widget = self.query_one("#standard-log")
119+
widget.add_line(line)
120+
121+
elif msg_type == "process_finished":
122+
self.process_running = False
123+
rc = data.get("rc", 0)
124+
self.query_one("#status").rc = rc

src/borg/cockpit/cockpit.tcss

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/* Borg Cockpit Stylesheet */
2+
3+
Screen {
4+
background: $surface;
5+
}
6+
7+
Header {
8+
dock: top;
9+
background: $primary;
10+
color: $secondary;
11+
text-style: bold;
12+
}
13+
14+
Header * {
15+
background: $primary;
16+
color: $secondary;
17+
text-style: bold;
18+
}
19+
20+
.header--clock, .header--title, .header--icon {
21+
background: $primary;
22+
color: $secondary;
23+
text-style: bold;
24+
}
25+
26+
.header--clock {
27+
dock: right;
28+
}
29+
30+
Footer {
31+
background: $background;
32+
color: $primary;
33+
dock: bottom;
34+
}
35+
36+
.footer--key {
37+
background: $background;
38+
color: $primary;
39+
text-style: bold;
40+
}
41+
42+
.footer--description {
43+
background: $background;
44+
color: $primary;
45+
text-style: bold;
46+
}
47+
48+
.footer--highlight {
49+
background: $primary;
50+
color: $secondary;
51+
}
52+
53+
#standard-log-content {
54+
scrollbar-background: $background;
55+
scrollbar-color: $primary;
56+
/* Hide horizontal scrollbar and clip long lines at the right */
57+
overflow-x: hidden;
58+
text-wrap: nowrap;
59+
}
60+
61+
#standard-log {
62+
border: double $primary;
63+
}
64+
65+
#main-grid {
66+
/* Simple vertical stack: top row content-sized, log fills remaining space */
67+
layout: vertical;
68+
/* Fill available area between header and footer */
69+
height: 1fr;
70+
/* Allow shrinking when space is tight */
71+
min-height: 0;
72+
margin: 0 1;
73+
}
74+
75+
#top-row {
76+
border: double $primary;
77+
/* If content grows too large, scroll rather than pushing the log off-screen */
78+
overflow-y: auto;
79+
/* Adjust this if status or logo panel shall get more/less height. */
80+
height: 16;
81+
}
82+
83+
#logopanel {
84+
width: 50%;
85+
/* Stretch to the full height of the top row so the separator spans fully */
86+
height: 100%;
87+
border-right: double $primary;
88+
text-align: center;
89+
layers: base overlay;
90+
/* Make logo panel not influence row height beyond status; clip overflow */
91+
overflow: hidden;
92+
}
93+
94+
Starfield {
95+
layer: base;
96+
width: 100%;
97+
/* Size to content and get clipped by the panel */
98+
height: 100%;
99+
min-height: 0;
100+
}
101+
102+
Pulsar {
103+
layer: overlay;
104+
width: 3;
105+
height: 3;
106+
content-align: center middle;
107+
color: $pulsar-color;
108+
transition: color 4s linear;
109+
}
110+
111+
Slogan {
112+
layer: overlay;
113+
width: auto;
114+
height: 1;
115+
content-align: center middle;
116+
color: #00ff00;
117+
transition: color 1s linear;
118+
opacity: 0;
119+
max-height: 100%;
120+
overflow: hidden;
121+
}
122+
123+
Logo {
124+
layer: overlay;
125+
width: auto;
126+
/* Size to its intrinsic content, clipped by the panel */
127+
height: auto;
128+
opacity: 0;
129+
max-height: 100%;
130+
overflow: hidden;
131+
}
132+
133+
Slogan.dim {
134+
color: #005500;
135+
}
136+
137+
Pulsar.dim {
138+
color: $pulsar-dim-color;
139+
}
140+
141+
#status {
142+
width: 50%;
143+
/* Let height be determined by content so the row can size to content */
144+
height: auto;
145+
/* Prevent internal content from forcing excessive height; allow scrolling */
146+
overflow-y: auto;
147+
}
148+
149+
/* Ensure the log always keeps at least 5 rows visible */
150+
#standard-log {
151+
min-height: 5;
152+
/* Explicitly claim the remaining space in the grid */
153+
height: 1fr;
154+
}
155+
156+
/* Within the log panel (a Vertical container), keep the title to 1 line and let content fill the rest */
157+
#standard-log-title {
158+
height: 1;
159+
}
160+
161+
#standard-log-content {
162+
/* Allow the RichLog to expand within the log panel */
163+
height: 1fr;
164+
}
165+
166+
.panel-title {
167+
background: $primary;
168+
color: $secondary;
169+
padding: 0 1;
170+
text-style: bold;
171+
}
172+
173+
#speed-sparkline {
174+
width: 100%;
175+
height: 4;
176+
margin-bottom: 1;
177+
}
178+
179+
.status {
180+
color: $primary;
181+
}
182+
183+
.errors-ok {
184+
color: $success;
185+
}
186+
187+
.errors-warning {
188+
color: $warning;
189+
}
190+
191+
.rc-ok {
192+
color: $success;
193+
}
194+
195+
.rc-warning {
196+
color: $warning;
197+
}
198+
199+
.rc-error {
200+
color: $error;
201+
}

0 commit comments

Comments
 (0)