hpc-runner 0.2.0__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.
Files changed (56) hide show
  1. hpc_runner/__init__.py +57 -0
  2. hpc_runner/_version.py +34 -0
  3. hpc_runner/cli/__init__.py +1 -0
  4. hpc_runner/cli/cancel.py +38 -0
  5. hpc_runner/cli/config.py +109 -0
  6. hpc_runner/cli/main.py +76 -0
  7. hpc_runner/cli/monitor.py +30 -0
  8. hpc_runner/cli/run.py +292 -0
  9. hpc_runner/cli/status.py +66 -0
  10. hpc_runner/core/__init__.py +31 -0
  11. hpc_runner/core/config.py +177 -0
  12. hpc_runner/core/descriptors.py +110 -0
  13. hpc_runner/core/exceptions.py +38 -0
  14. hpc_runner/core/job.py +328 -0
  15. hpc_runner/core/job_array.py +58 -0
  16. hpc_runner/core/job_info.py +104 -0
  17. hpc_runner/core/resources.py +49 -0
  18. hpc_runner/core/result.py +161 -0
  19. hpc_runner/core/types.py +13 -0
  20. hpc_runner/py.typed +0 -0
  21. hpc_runner/schedulers/__init__.py +60 -0
  22. hpc_runner/schedulers/base.py +194 -0
  23. hpc_runner/schedulers/detection.py +52 -0
  24. hpc_runner/schedulers/local/__init__.py +5 -0
  25. hpc_runner/schedulers/local/scheduler.py +354 -0
  26. hpc_runner/schedulers/local/templates/job.sh.j2 +28 -0
  27. hpc_runner/schedulers/sge/__init__.py +5 -0
  28. hpc_runner/schedulers/sge/args.py +232 -0
  29. hpc_runner/schedulers/sge/parser.py +287 -0
  30. hpc_runner/schedulers/sge/scheduler.py +881 -0
  31. hpc_runner/schedulers/sge/templates/batch.sh.j2 +82 -0
  32. hpc_runner/schedulers/sge/templates/interactive.sh.j2 +78 -0
  33. hpc_runner/templates/__init__.py +5 -0
  34. hpc_runner/templates/engine.py +55 -0
  35. hpc_runner/tui/__init__.py +5 -0
  36. hpc_runner/tui/app.py +436 -0
  37. hpc_runner/tui/components/__init__.py +17 -0
  38. hpc_runner/tui/components/detail_panel.py +187 -0
  39. hpc_runner/tui/components/filter_bar.py +174 -0
  40. hpc_runner/tui/components/filter_popup.py +345 -0
  41. hpc_runner/tui/components/job_table.py +260 -0
  42. hpc_runner/tui/providers/__init__.py +5 -0
  43. hpc_runner/tui/providers/jobs.py +197 -0
  44. hpc_runner/tui/screens/__init__.py +7 -0
  45. hpc_runner/tui/screens/confirm.py +67 -0
  46. hpc_runner/tui/screens/job_details.py +210 -0
  47. hpc_runner/tui/screens/log_viewer.py +170 -0
  48. hpc_runner/tui/snapshot.py +153 -0
  49. hpc_runner/tui/styles/monitor.tcss +567 -0
  50. hpc_runner/workflow/__init__.py +6 -0
  51. hpc_runner/workflow/dependency.py +20 -0
  52. hpc_runner/workflow/pipeline.py +180 -0
  53. hpc_runner-0.2.0.dist-info/METADATA +285 -0
  54. hpc_runner-0.2.0.dist-info/RECORD +56 -0
  55. hpc_runner-0.2.0.dist-info/WHEEL +4 -0
  56. hpc_runner-0.2.0.dist-info/entry_points.txt +2 -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