hpc-runner 0.1.1__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hpc_runner/_version.py +2 -2
- hpc_runner/cli/cancel.py +1 -1
- hpc_runner/cli/config.py +2 -2
- hpc_runner/cli/main.py +17 -13
- hpc_runner/cli/monitor.py +30 -0
- hpc_runner/cli/run.py +223 -67
- hpc_runner/cli/status.py +6 -5
- hpc_runner/core/__init__.py +30 -0
- hpc_runner/core/descriptors.py +87 -33
- hpc_runner/core/exceptions.py +9 -0
- hpc_runner/core/job.py +272 -93
- hpc_runner/core/job_info.py +104 -0
- hpc_runner/core/result.py +4 -0
- hpc_runner/schedulers/base.py +148 -30
- hpc_runner/schedulers/detection.py +22 -4
- hpc_runner/schedulers/local/scheduler.py +119 -2
- hpc_runner/schedulers/sge/args.py +161 -94
- hpc_runner/schedulers/sge/parser.py +106 -13
- hpc_runner/schedulers/sge/scheduler.py +727 -171
- hpc_runner/schedulers/sge/templates/batch.sh.j2 +82 -0
- hpc_runner/schedulers/sge/templates/interactive.sh.j2 +78 -0
- hpc_runner/tui/__init__.py +5 -0
- hpc_runner/tui/app.py +436 -0
- hpc_runner/tui/components/__init__.py +17 -0
- hpc_runner/tui/components/detail_panel.py +187 -0
- hpc_runner/tui/components/filter_bar.py +174 -0
- hpc_runner/tui/components/filter_popup.py +345 -0
- hpc_runner/tui/components/job_table.py +260 -0
- hpc_runner/tui/providers/__init__.py +5 -0
- hpc_runner/tui/providers/jobs.py +197 -0
- hpc_runner/tui/screens/__init__.py +7 -0
- hpc_runner/tui/screens/confirm.py +67 -0
- hpc_runner/tui/screens/job_details.py +210 -0
- hpc_runner/tui/screens/log_viewer.py +170 -0
- hpc_runner/tui/snapshot.py +153 -0
- hpc_runner/tui/styles/monitor.tcss +567 -0
- hpc_runner-0.2.1.dist-info/METADATA +285 -0
- hpc_runner-0.2.1.dist-info/RECORD +56 -0
- hpc_runner/schedulers/sge/templates/job.sh.j2 +0 -39
- hpc_runner-0.1.1.dist-info/METADATA +0 -46
- hpc_runner-0.1.1.dist-info/RECORD +0 -38
- {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/WHEEL +0 -0
- {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Job detail panel component."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.events import Key
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.widgets import Button, Static
|
|
13
|
+
|
|
14
|
+
from hpc_runner.core.job_info import JobInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ButtonBar(Horizontal):
|
|
18
|
+
"""Horizontal container with arrow key navigation between buttons."""
|
|
19
|
+
|
|
20
|
+
def on_key(self, event: Key) -> None:
|
|
21
|
+
"""Handle arrow key navigation between buttons."""
|
|
22
|
+
if event.key not in ("left", "right"):
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
buttons = [btn for btn in self.query(Button).results(Button) if not btn.disabled]
|
|
26
|
+
if not buttons:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
focused = self.app.focused
|
|
30
|
+
if not isinstance(focused, Button) or focused not in buttons:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
idx = buttons.index(focused)
|
|
34
|
+
if event.key == "right":
|
|
35
|
+
next_idx = (idx + 1) % len(buttons)
|
|
36
|
+
else:
|
|
37
|
+
next_idx = (idx - 1) % len(buttons)
|
|
38
|
+
|
|
39
|
+
buttons[next_idx].focus()
|
|
40
|
+
event.prevent_default()
|
|
41
|
+
event.stop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DetailPanel(Vertical):
|
|
45
|
+
"""Panel showing detailed information for selected job.
|
|
46
|
+
|
|
47
|
+
Styles are defined in monitor.tcss, not DEFAULT_CSS.
|
|
48
|
+
Arrow keys navigate between buttons.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
class ViewLogs(Message):
|
|
52
|
+
"""Request to view job logs."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, job: JobInfo, stream: str) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.job = job
|
|
57
|
+
self.stream = stream # "stdout" or "stderr"
|
|
58
|
+
|
|
59
|
+
class CancelJob(Message):
|
|
60
|
+
"""Request to cancel a job."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, job: JobInfo) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.job = job
|
|
65
|
+
|
|
66
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
67
|
+
super().__init__(**kwargs)
|
|
68
|
+
self._job: JobInfo | None = None
|
|
69
|
+
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
"""Create panel content."""
|
|
72
|
+
yield Static("Select a job to view details", id="no-selection")
|
|
73
|
+
with Vertical(id="detail-content", classes="hidden"):
|
|
74
|
+
# Row 1: ID and Status
|
|
75
|
+
with Horizontal(classes="detail-row"):
|
|
76
|
+
yield Static("Job ID:", classes="detail-label")
|
|
77
|
+
yield Static("", id="detail-id", classes="detail-value")
|
|
78
|
+
yield Static("Status:", classes="detail-label")
|
|
79
|
+
yield Static("", id="detail-status", classes="detail-value")
|
|
80
|
+
|
|
81
|
+
# Row 2: Queue and Runtime
|
|
82
|
+
with Horizontal(classes="detail-row"):
|
|
83
|
+
yield Static("Queue:", classes="detail-label")
|
|
84
|
+
yield Static("", id="detail-queue", classes="detail-value")
|
|
85
|
+
yield Static("Runtime:", classes="detail-label")
|
|
86
|
+
yield Static("", id="detail-runtime", classes="detail-value")
|
|
87
|
+
|
|
88
|
+
# Row 3: Resources and Node
|
|
89
|
+
with Horizontal(classes="detail-row"):
|
|
90
|
+
yield Static("Resources:", classes="detail-label")
|
|
91
|
+
yield Static("", id="detail-resources", classes="detail-value")
|
|
92
|
+
yield Static("Node:", classes="detail-label")
|
|
93
|
+
yield Static("", id="detail-node", classes="detail-value")
|
|
94
|
+
|
|
95
|
+
# Row 4: User and Submit time
|
|
96
|
+
with Horizontal(classes="detail-row"):
|
|
97
|
+
yield Static("User:", classes="detail-label")
|
|
98
|
+
yield Static("", id="detail-user", classes="detail-value")
|
|
99
|
+
yield Static("Submitted:", classes="detail-label")
|
|
100
|
+
yield Static("", id="detail-submitted", classes="detail-value")
|
|
101
|
+
|
|
102
|
+
# Row 5: Output path
|
|
103
|
+
with Horizontal(classes="detail-row"):
|
|
104
|
+
yield Static("Output:", classes="detail-label")
|
|
105
|
+
yield Static("", id="detail-output", classes="detail-value")
|
|
106
|
+
|
|
107
|
+
# Buttons (styled via monitor.tcss)
|
|
108
|
+
with ButtonBar(id="detail-buttons"):
|
|
109
|
+
yield Button("View stdout", id="btn-stdout")
|
|
110
|
+
yield Button("View stderr", id="btn-stderr")
|
|
111
|
+
yield Button("Cancel", id="btn-cancel")
|
|
112
|
+
|
|
113
|
+
def on_mount(self) -> None:
|
|
114
|
+
"""Set up the panel."""
|
|
115
|
+
self.border_title = "Job Details"
|
|
116
|
+
|
|
117
|
+
def update_job(self, job: JobInfo | None) -> None:
|
|
118
|
+
"""Update the panel with job details."""
|
|
119
|
+
self._job = job
|
|
120
|
+
|
|
121
|
+
no_selection = self.query_one("#no-selection", Static)
|
|
122
|
+
detail_content = self.query_one("#detail-content", Vertical)
|
|
123
|
+
|
|
124
|
+
if job is None:
|
|
125
|
+
no_selection.remove_class("hidden")
|
|
126
|
+
detail_content.add_class("hidden")
|
|
127
|
+
self.border_title = "Job Details"
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
no_selection.add_class("hidden")
|
|
131
|
+
detail_content.remove_class("hidden")
|
|
132
|
+
|
|
133
|
+
# Update border title
|
|
134
|
+
self.border_title = f"Job: {job.name}"
|
|
135
|
+
|
|
136
|
+
# Update fields
|
|
137
|
+
self.query_one("#detail-id", Static).update(job.job_id)
|
|
138
|
+
self.query_one("#detail-status", Static).update(job.status.name)
|
|
139
|
+
self.query_one("#detail-queue", Static).update(job.queue or "—")
|
|
140
|
+
self.query_one("#detail-runtime", Static).update(job.runtime_display)
|
|
141
|
+
self.query_one("#detail-resources", Static).update(job.resources_display)
|
|
142
|
+
self.query_one("#detail-node", Static).update(job.node or "—")
|
|
143
|
+
self.query_one("#detail-user", Static).update(job.user)
|
|
144
|
+
|
|
145
|
+
# Format submit time
|
|
146
|
+
if job.submit_time:
|
|
147
|
+
submitted = job.submit_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
148
|
+
else:
|
|
149
|
+
submitted = "—"
|
|
150
|
+
self.query_one("#detail-submitted", Static).update(submitted)
|
|
151
|
+
|
|
152
|
+
# Format output path
|
|
153
|
+
if job.stdout_path:
|
|
154
|
+
output = str(job.stdout_path)
|
|
155
|
+
# Truncate if too long
|
|
156
|
+
if len(output) > 50:
|
|
157
|
+
output = "..." + output[-47:]
|
|
158
|
+
else:
|
|
159
|
+
output = "—"
|
|
160
|
+
self.query_one("#detail-output", Static).update(output)
|
|
161
|
+
|
|
162
|
+
# Update button states
|
|
163
|
+
btn_stdout = self.query_one("#btn-stdout", Button)
|
|
164
|
+
btn_stderr = self.query_one("#btn-stderr", Button)
|
|
165
|
+
btn_cancel = self.query_one("#btn-cancel", Button)
|
|
166
|
+
|
|
167
|
+
btn_stdout.disabled = job.stdout_path is None
|
|
168
|
+
btn_stderr.disabled = job.stderr_path is None
|
|
169
|
+
btn_cancel.disabled = not job.is_active
|
|
170
|
+
|
|
171
|
+
@on(Button.Pressed, "#btn-stdout")
|
|
172
|
+
def on_view_stdout(self, event: Button.Pressed) -> None:
|
|
173
|
+
"""Handle stdout button click."""
|
|
174
|
+
if self._job:
|
|
175
|
+
self.post_message(self.ViewLogs(self._job, "stdout"))
|
|
176
|
+
|
|
177
|
+
@on(Button.Pressed, "#btn-stderr")
|
|
178
|
+
def on_view_stderr(self, event: Button.Pressed) -> None:
|
|
179
|
+
"""Handle stderr button click."""
|
|
180
|
+
if self._job:
|
|
181
|
+
self.post_message(self.ViewLogs(self._job, "stderr"))
|
|
182
|
+
|
|
183
|
+
@on(Button.Pressed, "#btn-cancel")
|
|
184
|
+
def on_cancel_job(self, event: Button.Pressed) -> None:
|
|
185
|
+
"""Handle cancel button click."""
|
|
186
|
+
if self._job:
|
|
187
|
+
self.post_message(self.CancelJob(self._job))
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Filter bar component for job tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from textual.containers import Horizontal
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.widgets import Input, Select, Static
|
|
11
|
+
|
|
12
|
+
from hpc_runner.core.result import JobStatus
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Status options for the dropdown
|
|
19
|
+
STATUS_OPTIONS: list[tuple[str, str | None]] = [
|
|
20
|
+
("All", None),
|
|
21
|
+
("Running", "running"),
|
|
22
|
+
("Pending", "pending"),
|
|
23
|
+
("Held", "held"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FilterBar(Horizontal):
|
|
28
|
+
"""Composable filter bar for job tables.
|
|
29
|
+
|
|
30
|
+
Provides status filter, queue filter, and search input.
|
|
31
|
+
Emits FilterChanged messages when any filter value changes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FilterChanged(Message):
|
|
36
|
+
"""Emitted when any filter value changes."""
|
|
37
|
+
|
|
38
|
+
status: str | None
|
|
39
|
+
queue: str | None
|
|
40
|
+
search: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def control(self) -> FilterBar:
|
|
44
|
+
"""The FilterBar that sent this message."""
|
|
45
|
+
return self._sender # type: ignore[return-value]
|
|
46
|
+
|
|
47
|
+
DEFAULT_CSS = """
|
|
48
|
+
FilterBar {
|
|
49
|
+
height: 3;
|
|
50
|
+
padding: 0 1;
|
|
51
|
+
background: transparent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
FilterBar > Static.filter-label {
|
|
55
|
+
width: auto;
|
|
56
|
+
padding: 0 1 0 0;
|
|
57
|
+
content-align: center middle;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
FilterBar > Select {
|
|
61
|
+
width: 16;
|
|
62
|
+
margin-right: 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
FilterBar > Input {
|
|
66
|
+
width: 24;
|
|
67
|
+
}
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
queues: list[str] | None = None,
|
|
73
|
+
*,
|
|
74
|
+
id: str | None = None,
|
|
75
|
+
classes: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize the filter bar.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
queues: List of queue names for the queue dropdown.
|
|
81
|
+
id: Widget ID.
|
|
82
|
+
classes: CSS classes.
|
|
83
|
+
"""
|
|
84
|
+
super().__init__(id=id, classes=classes)
|
|
85
|
+
self._queues = queues or []
|
|
86
|
+
self._current_status: str | None = None
|
|
87
|
+
self._current_queue: str | None = None
|
|
88
|
+
self._current_search: str = ""
|
|
89
|
+
|
|
90
|
+
def compose(self) -> ComposeResult:
|
|
91
|
+
"""Create filter bar widgets."""
|
|
92
|
+
yield Static("Status:", classes="filter-label")
|
|
93
|
+
yield Select(
|
|
94
|
+
STATUS_OPTIONS,
|
|
95
|
+
value=None,
|
|
96
|
+
allow_blank=False,
|
|
97
|
+
id="status-filter",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
yield Static("Queue:", classes="filter-label")
|
|
101
|
+
queue_options: list[tuple[str, str | None]] = [("All", None)]
|
|
102
|
+
queue_options.extend((q, q) for q in self._queues)
|
|
103
|
+
yield Select(
|
|
104
|
+
queue_options,
|
|
105
|
+
value=None,
|
|
106
|
+
allow_blank=False,
|
|
107
|
+
id="queue-filter",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
yield Input(placeholder="Search name/ID...", id="search-filter")
|
|
111
|
+
|
|
112
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
113
|
+
"""Handle dropdown selection changes."""
|
|
114
|
+
if event.select.id == "status-filter":
|
|
115
|
+
self._current_status = event.value
|
|
116
|
+
elif event.select.id == "queue-filter":
|
|
117
|
+
self._current_queue = event.value
|
|
118
|
+
|
|
119
|
+
self._emit_filter_changed()
|
|
120
|
+
|
|
121
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
122
|
+
"""Handle search input changes."""
|
|
123
|
+
if event.input.id == "search-filter":
|
|
124
|
+
self._current_search = event.value
|
|
125
|
+
self._emit_filter_changed()
|
|
126
|
+
|
|
127
|
+
def _emit_filter_changed(self) -> None:
|
|
128
|
+
"""Emit a FilterChanged message with current filter values."""
|
|
129
|
+
self.post_message(
|
|
130
|
+
self.FilterChanged(
|
|
131
|
+
status=self._current_status,
|
|
132
|
+
queue=self._current_queue,
|
|
133
|
+
search=self._current_search,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def update_queues(self, queues: list[str]) -> None:
|
|
138
|
+
"""Update the queue dropdown options.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
queues: New list of queue names.
|
|
142
|
+
"""
|
|
143
|
+
self._queues = queues
|
|
144
|
+
try:
|
|
145
|
+
queue_select = self.query_one("#queue-filter", Select)
|
|
146
|
+
queue_options: list[tuple[str, str | None]] = [("All", None)]
|
|
147
|
+
queue_options.extend((q, q) for q in queues)
|
|
148
|
+
queue_select.set_options(queue_options)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass # Widget not yet mounted
|
|
151
|
+
|
|
152
|
+
def clear_filters(self) -> None:
|
|
153
|
+
"""Reset all filters to default values."""
|
|
154
|
+
try:
|
|
155
|
+
self.query_one("#status-filter", Select).value = None
|
|
156
|
+
self.query_one("#queue-filter", Select).value = None
|
|
157
|
+
self.query_one("#search-filter", Input).value = ""
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def status_filter(self) -> str | None:
|
|
163
|
+
"""Current status filter value."""
|
|
164
|
+
return self._current_status
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def queue_filter(self) -> str | None:
|
|
168
|
+
"""Current queue filter value."""
|
|
169
|
+
return self._current_queue
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def search_filter(self) -> str:
|
|
173
|
+
"""Current search filter value."""
|
|
174
|
+
return self._current_search
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Filter popup components for job tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual import events, on
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Input, OptionList, Static
|
|
9
|
+
from textual.widgets.option_list import Option
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HelpPopup(Static, can_focus=True):
|
|
13
|
+
"""Centered help popup with keybindings."""
|
|
14
|
+
|
|
15
|
+
HELP_TEXT = """\
|
|
16
|
+
Keybindings
|
|
17
|
+
|
|
18
|
+
q Quit
|
|
19
|
+
r Refresh jobs
|
|
20
|
+
u Toggle user (me/all)
|
|
21
|
+
/ Focus search
|
|
22
|
+
s Save screenshot
|
|
23
|
+
Tab Navigate panels
|
|
24
|
+
↑/↓ jk Cycle filter options
|
|
25
|
+
Enter Open filter popup
|
|
26
|
+
Esc Close popup\
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
DEFAULT_CSS = """
|
|
30
|
+
HelpPopup {
|
|
31
|
+
layer: overlay;
|
|
32
|
+
width: auto;
|
|
33
|
+
height: auto;
|
|
34
|
+
background: transparent;
|
|
35
|
+
border: round $primary;
|
|
36
|
+
border-title-color: $primary;
|
|
37
|
+
padding: 0 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
HelpPopup.hidden {
|
|
41
|
+
display: none;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, **kwargs) -> None:
|
|
46
|
+
super().__init__(self.HELP_TEXT, **kwargs)
|
|
47
|
+
self.add_class("hidden")
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
"""Set up the popup."""
|
|
51
|
+
self.border_title = "Help"
|
|
52
|
+
|
|
53
|
+
def show_popup(self) -> None:
|
|
54
|
+
"""Show the popup centered on screen."""
|
|
55
|
+
self.remove_class("hidden")
|
|
56
|
+
# Calculate size based on content
|
|
57
|
+
lines = self.HELP_TEXT.split("\n")
|
|
58
|
+
width = max(len(line) for line in lines) + 4
|
|
59
|
+
height = len(lines) + 2
|
|
60
|
+
self.styles.width = width
|
|
61
|
+
self.styles.height = height
|
|
62
|
+
self.styles.offset = (
|
|
63
|
+
(self.app.size.width - width) // 2,
|
|
64
|
+
(self.app.size.height - height) // 2,
|
|
65
|
+
)
|
|
66
|
+
self.focus()
|
|
67
|
+
|
|
68
|
+
def hide_popup(self) -> None:
|
|
69
|
+
"""Hide the popup."""
|
|
70
|
+
self.add_class("hidden")
|
|
71
|
+
|
|
72
|
+
@on(events.Key)
|
|
73
|
+
def on_key(self, event: events.Key) -> None:
|
|
74
|
+
"""Hide on any key press."""
|
|
75
|
+
self.hide_popup()
|
|
76
|
+
event.stop()
|
|
77
|
+
|
|
78
|
+
@on(events.Blur)
|
|
79
|
+
def on_blur(self, event: events.Blur) -> None:
|
|
80
|
+
"""Hide on blur."""
|
|
81
|
+
self.hide_popup()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FilterPanelPopup(OptionList):
|
|
85
|
+
"""Popup for FilterPanel showing all options."""
|
|
86
|
+
|
|
87
|
+
SCOPED_CSS = False
|
|
88
|
+
|
|
89
|
+
DEFAULT_CSS = """
|
|
90
|
+
FilterPanelPopup {
|
|
91
|
+
layer: overlay;
|
|
92
|
+
width: auto;
|
|
93
|
+
height: auto;
|
|
94
|
+
max-height: 10;
|
|
95
|
+
min-width: 16;
|
|
96
|
+
background: transparent;
|
|
97
|
+
border: round $primary !important;
|
|
98
|
+
padding: 0 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
FilterPanelPopup > .option-list--option {
|
|
102
|
+
background: transparent;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
FilterPanelPopup > .option-list--option-highlighted {
|
|
106
|
+
background: $primary;
|
|
107
|
+
color: $background;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
FilterPanelPopup.hidden {
|
|
111
|
+
display: none;
|
|
112
|
+
}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
options: list[tuple[str, str | None]],
|
|
118
|
+
current_index: int = 0,
|
|
119
|
+
on_select: callable = None,
|
|
120
|
+
**kwargs,
|
|
121
|
+
) -> None:
|
|
122
|
+
super().__init__(**kwargs)
|
|
123
|
+
self._panel_options = options
|
|
124
|
+
self._current_index = current_index
|
|
125
|
+
self._on_select = on_select
|
|
126
|
+
|
|
127
|
+
def on_mount(self) -> None:
|
|
128
|
+
"""Populate options."""
|
|
129
|
+
self._refresh_options()
|
|
130
|
+
|
|
131
|
+
def _refresh_options(self) -> None:
|
|
132
|
+
"""Refresh the option list."""
|
|
133
|
+
self.clear_options()
|
|
134
|
+
for label, value in self._panel_options:
|
|
135
|
+
self.add_option(Option(f" {label} ", id=str(value) if value else "none"))
|
|
136
|
+
if self._current_index < len(self._panel_options):
|
|
137
|
+
self.highlighted = self._current_index
|
|
138
|
+
|
|
139
|
+
def update_options(
|
|
140
|
+
self,
|
|
141
|
+
options: list[tuple[str, str | None]],
|
|
142
|
+
current_index: int,
|
|
143
|
+
on_select: callable = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Update options and current selection."""
|
|
146
|
+
self._panel_options = options
|
|
147
|
+
self._current_index = current_index
|
|
148
|
+
if on_select is not None:
|
|
149
|
+
self._on_select = on_select
|
|
150
|
+
if self.is_mounted:
|
|
151
|
+
self._refresh_options()
|
|
152
|
+
|
|
153
|
+
def show_popup(self, region) -> None:
|
|
154
|
+
"""Show popup positioned relative to parent widget."""
|
|
155
|
+
self.remove_class("hidden")
|
|
156
|
+
self.styles.offset = (region.x, region.y + region.height)
|
|
157
|
+
self.focus()
|
|
158
|
+
|
|
159
|
+
def hide_popup(self) -> None:
|
|
160
|
+
"""Hide the popup."""
|
|
161
|
+
self.add_class("hidden")
|
|
162
|
+
|
|
163
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
164
|
+
"""Handle option selection."""
|
|
165
|
+
index = self.highlighted if self.highlighted is not None else 0
|
|
166
|
+
self.hide_popup()
|
|
167
|
+
if self._on_select is not None:
|
|
168
|
+
self._on_select(index)
|
|
169
|
+
|
|
170
|
+
@on(events.Key)
|
|
171
|
+
def on_key_escape(self, event: events.Key) -> None:
|
|
172
|
+
"""Hide on escape."""
|
|
173
|
+
if event.key == "escape":
|
|
174
|
+
self.hide_popup()
|
|
175
|
+
event.stop()
|
|
176
|
+
|
|
177
|
+
@on(events.Blur)
|
|
178
|
+
def on_blur(self, event: events.Blur) -> None:
|
|
179
|
+
"""Hide on blur."""
|
|
180
|
+
self.hide_popup()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class FilterPanel(Static, can_focus=True):
|
|
184
|
+
"""A focusable filter panel that cycles through options with arrow keys.
|
|
185
|
+
|
|
186
|
+
Press Enter to open a popup showing all options.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
class FilterChanged(Message):
|
|
190
|
+
"""Emitted when filter value changes."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, filter_type: str, value: str | None) -> None:
|
|
193
|
+
super().__init__()
|
|
194
|
+
self.filter_type = filter_type
|
|
195
|
+
self.value = value
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
filter_type: str,
|
|
200
|
+
options: list[tuple[str, str | None]],
|
|
201
|
+
title: str = "",
|
|
202
|
+
**kwargs,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Initialize filter panel.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
filter_type: Type of filter (e.g., "status", "queue")
|
|
208
|
+
options: List of (display_label, value) tuples
|
|
209
|
+
title: Border title
|
|
210
|
+
"""
|
|
211
|
+
super().__init__("All", **kwargs)
|
|
212
|
+
self._filter_type = filter_type
|
|
213
|
+
self._options = options
|
|
214
|
+
self._current_index = 0
|
|
215
|
+
self._title = title
|
|
216
|
+
self._popup: FilterPanelPopup | None = None
|
|
217
|
+
|
|
218
|
+
def on_mount(self) -> None:
|
|
219
|
+
"""Set border title and create popup on mount."""
|
|
220
|
+
self.border_title = self._title
|
|
221
|
+
self._popup = FilterPanelPopup(
|
|
222
|
+
self._options,
|
|
223
|
+
self._current_index,
|
|
224
|
+
on_select=self._on_popup_select,
|
|
225
|
+
id=f"{self._filter_type}-popup",
|
|
226
|
+
)
|
|
227
|
+
self._popup.add_class("hidden")
|
|
228
|
+
self.app.mount(self._popup)
|
|
229
|
+
|
|
230
|
+
def _on_popup_select(self, index: int) -> None:
|
|
231
|
+
"""Handle popup selection callback."""
|
|
232
|
+
self._current_index = index
|
|
233
|
+
self._update_display()
|
|
234
|
+
self.focus()
|
|
235
|
+
|
|
236
|
+
def on_key(self, event: events.Key) -> None:
|
|
237
|
+
"""Handle arrow keys to cycle through options, Enter to open popup."""
|
|
238
|
+
if event.key == "down" or event.key == "j":
|
|
239
|
+
self._current_index = (self._current_index + 1) % len(self._options)
|
|
240
|
+
self._update_display()
|
|
241
|
+
event.stop()
|
|
242
|
+
elif event.key == "up" or event.key == "k":
|
|
243
|
+
self._current_index = (self._current_index - 1) % len(self._options)
|
|
244
|
+
self._update_display()
|
|
245
|
+
event.stop()
|
|
246
|
+
elif event.key == "enter" or event.key == "space":
|
|
247
|
+
self._show_popup()
|
|
248
|
+
event.stop()
|
|
249
|
+
|
|
250
|
+
def _show_popup(self) -> None:
|
|
251
|
+
"""Show the options popup."""
|
|
252
|
+
if self._popup is not None:
|
|
253
|
+
self._popup.update_options(
|
|
254
|
+
self._options,
|
|
255
|
+
self._current_index,
|
|
256
|
+
on_select=self._on_popup_select,
|
|
257
|
+
)
|
|
258
|
+
self._popup.show_popup(self.region)
|
|
259
|
+
|
|
260
|
+
def _update_display(self) -> None:
|
|
261
|
+
"""Update the displayed value and emit change event."""
|
|
262
|
+
label, value = self._options[self._current_index]
|
|
263
|
+
self.update(label)
|
|
264
|
+
self.post_message(self.FilterChanged(self._filter_type, value))
|
|
265
|
+
|
|
266
|
+
def set_options(self, options: list[tuple[str, str | None]]) -> None:
|
|
267
|
+
"""Update available options."""
|
|
268
|
+
self._options = options
|
|
269
|
+
if self._current_index >= len(options):
|
|
270
|
+
self._current_index = 0
|
|
271
|
+
self._update_display()
|
|
272
|
+
|
|
273
|
+
def get_value(self) -> str | None:
|
|
274
|
+
"""Get current filter value."""
|
|
275
|
+
return self._options[self._current_index][1]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class FilterStatusLine(Horizontal):
|
|
279
|
+
"""Filter status bar with bordered panels.
|
|
280
|
+
|
|
281
|
+
Shows: [Job Status] [Queue] [Search.....................]
|
|
282
|
+
Tab into Status/Queue and use up/down arrows to change.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
class SearchChanged(Message):
|
|
286
|
+
"""Emitted when search value changes."""
|
|
287
|
+
|
|
288
|
+
def __init__(self, value: str) -> None:
|
|
289
|
+
super().__init__()
|
|
290
|
+
self.value = value
|
|
291
|
+
|
|
292
|
+
STATUS_OPTIONS: list[tuple[str, str | None]] = [
|
|
293
|
+
("All", None),
|
|
294
|
+
("Running", "running"),
|
|
295
|
+
("Pending", "pending"),
|
|
296
|
+
("Held", "held"),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
def __init__(self, **kwargs) -> None:
|
|
300
|
+
super().__init__(id="filter-status", **kwargs)
|
|
301
|
+
self._search: str = ""
|
|
302
|
+
self._queues: list[str] = []
|
|
303
|
+
|
|
304
|
+
def compose(self):
|
|
305
|
+
"""Create the status line widgets."""
|
|
306
|
+
yield FilterPanel(
|
|
307
|
+
"status",
|
|
308
|
+
self.STATUS_OPTIONS,
|
|
309
|
+
title="Status",
|
|
310
|
+
id="status-panel",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
yield FilterPanel(
|
|
314
|
+
"queue",
|
|
315
|
+
[("All", None)],
|
|
316
|
+
title="Queue",
|
|
317
|
+
id="queue-panel",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
with Vertical(id="search-container") as search_container:
|
|
321
|
+
search_container.border_title = "Search"
|
|
322
|
+
yield Input(placeholder="", id="search-input")
|
|
323
|
+
|
|
324
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
325
|
+
"""Handle search input changes."""
|
|
326
|
+
if event.input.id == "search-input":
|
|
327
|
+
self._search = event.value
|
|
328
|
+
self.post_message(self.SearchChanged(event.value))
|
|
329
|
+
|
|
330
|
+
def focus_search(self) -> None:
|
|
331
|
+
"""Focus the search input."""
|
|
332
|
+
self.query_one("#search-input", Input).focus()
|
|
333
|
+
|
|
334
|
+
def update_queues(self, queues: list[str]) -> None:
|
|
335
|
+
"""Update available queue options."""
|
|
336
|
+
self._queues = queues
|
|
337
|
+
options: list[tuple[str, str | None]] = [("All", None)]
|
|
338
|
+
options.extend((q, q) for q in queues)
|
|
339
|
+
self.query_one("#queue-panel", FilterPanel).set_options(options)
|
|
340
|
+
|
|
341
|
+
def update_search(self, value: str) -> None:
|
|
342
|
+
"""Update search display (only if different to avoid loops)."""
|
|
343
|
+
if self._search != value:
|
|
344
|
+
self._search = value
|
|
345
|
+
self.query_one("#search-input", Input).value = value
|