ferp 0.7.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.
- ferp/__init__.py +3 -0
- ferp/__main__.py +4 -0
- ferp/__version__.py +1 -0
- ferp/app.py +9 -0
- ferp/cli.py +160 -0
- ferp/core/__init__.py +0 -0
- ferp/core/app.py +1312 -0
- ferp/core/bundle_installer.py +245 -0
- ferp/core/command_provider.py +77 -0
- ferp/core/dependency_manager.py +59 -0
- ferp/core/fs_controller.py +70 -0
- ferp/core/fs_watcher.py +144 -0
- ferp/core/messages.py +49 -0
- ferp/core/path_actions.py +124 -0
- ferp/core/paths.py +3 -0
- ferp/core/protocols.py +8 -0
- ferp/core/script_controller.py +515 -0
- ferp/core/script_protocol.py +35 -0
- ferp/core/script_runner.py +421 -0
- ferp/core/settings.py +16 -0
- ferp/core/settings_store.py +69 -0
- ferp/core/state.py +156 -0
- ferp/core/task_store.py +164 -0
- ferp/core/transcript_logger.py +95 -0
- ferp/domain/__init__.py +0 -0
- ferp/domain/scripts.py +29 -0
- ferp/fscp/host/__init__.py +11 -0
- ferp/fscp/host/host.py +439 -0
- ferp/fscp/host/managed_process.py +113 -0
- ferp/fscp/host/process_registry.py +124 -0
- ferp/fscp/protocol/__init__.py +13 -0
- ferp/fscp/protocol/errors.py +2 -0
- ferp/fscp/protocol/messages.py +55 -0
- ferp/fscp/protocol/schemas/__init__.py +0 -0
- ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
- ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
- ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
- ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
- ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
- ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
- ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
- ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
- ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
- ferp/fscp/protocol/state.py +16 -0
- ferp/fscp/protocol/validator.py +123 -0
- ferp/fscp/scripts/__init__.py +0 -0
- ferp/fscp/scripts/runtime/__init__.py +4 -0
- ferp/fscp/scripts/runtime/__main__.py +40 -0
- ferp/fscp/scripts/runtime/errors.py +14 -0
- ferp/fscp/scripts/runtime/io.py +64 -0
- ferp/fscp/scripts/runtime/script.py +149 -0
- ferp/fscp/scripts/runtime/state.py +17 -0
- ferp/fscp/scripts/runtime/worker.py +13 -0
- ferp/fscp/scripts/sdk.py +548 -0
- ferp/fscp/transcript/__init__.py +3 -0
- ferp/fscp/transcript/events.py +14 -0
- ferp/resources/__init__.py +0 -0
- ferp/services/__init__.py +3 -0
- ferp/services/file_listing.py +120 -0
- ferp/services/monday_sync.py +155 -0
- ferp/services/releases.py +214 -0
- ferp/services/scripts.py +90 -0
- ferp/services/update_check.py +130 -0
- ferp/styles/index.tcss +638 -0
- ferp/themes/themes.py +238 -0
- ferp/widgets/__init__.py +17 -0
- ferp/widgets/dialogs.py +167 -0
- ferp/widgets/file_tree.py +991 -0
- ferp/widgets/forms.py +146 -0
- ferp/widgets/output_panel.py +244 -0
- ferp/widgets/panels.py +13 -0
- ferp/widgets/process_list.py +158 -0
- ferp/widgets/readme_modal.py +59 -0
- ferp/widgets/scripts.py +192 -0
- ferp/widgets/task_capture.py +74 -0
- ferp/widgets/task_list.py +493 -0
- ferp/widgets/top_bar.py +110 -0
- ferp-0.7.1.dist-info/METADATA +128 -0
- ferp-0.7.1.dist-info/RECORD +87 -0
- ferp-0.7.1.dist-info/WHEEL +5 -0
- ferp-0.7.1.dist-info/entry_points.txt +2 -0
- ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
- ferp-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.timer import Timer
|
|
12
|
+
from textual.widgets import (
|
|
13
|
+
Footer,
|
|
14
|
+
Input,
|
|
15
|
+
Label,
|
|
16
|
+
ListItem,
|
|
17
|
+
ListView,
|
|
18
|
+
LoadingIndicator,
|
|
19
|
+
Static,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from ferp.core.state import TaskListState, TaskListStateStore
|
|
23
|
+
from ferp.core.task_store import Task as TodoTask
|
|
24
|
+
from ferp.core.task_store import TaskStore
|
|
25
|
+
from ferp.widgets.dialogs import ConfirmDialog
|
|
26
|
+
from ferp.widgets.task_capture import TaskCaptureModal
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskEditModal(ModalScreen[str | None]):
|
|
30
|
+
"""Modal editor used for updating an existing task."""
|
|
31
|
+
|
|
32
|
+
BINDINGS = [
|
|
33
|
+
Binding("escape", "close", "Cancel", show=True),
|
|
34
|
+
Binding("enter", "submit", "Update", show=True),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self, initial_text: str) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._initial_text = initial_text
|
|
40
|
+
self._area: Input | None = None
|
|
41
|
+
self._status: Static | None = None
|
|
42
|
+
self._clear_timer: Timer | None = None
|
|
43
|
+
|
|
44
|
+
def compose(self):
|
|
45
|
+
self._area = Input(id="task_edit_input", placeholder="Edit task")
|
|
46
|
+
self._status = Static("", classes="task_edit_status")
|
|
47
|
+
yield Container(
|
|
48
|
+
Vertical(self._area, self._status, Footer()), id="task_edit_modal"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
area = self.query_one(Input)
|
|
53
|
+
container = self.query_one("#task_edit_modal", Container)
|
|
54
|
+
container.border_title = "Edit Task"
|
|
55
|
+
area.value = self._initial_text
|
|
56
|
+
area.focus()
|
|
57
|
+
self._area = area
|
|
58
|
+
|
|
59
|
+
@on(Input.Submitted, "#task_edit_input")
|
|
60
|
+
def _handle_submit(self, event: Input.Submitted) -> None:
|
|
61
|
+
event.stop()
|
|
62
|
+
self.action_submit()
|
|
63
|
+
|
|
64
|
+
def action_submit(self) -> None:
|
|
65
|
+
area = self._area or self.query_one(Input)
|
|
66
|
+
text = area.value.strip()
|
|
67
|
+
if not text:
|
|
68
|
+
self._set_status("[red]Task text required[/red]")
|
|
69
|
+
return
|
|
70
|
+
self._set_status("")
|
|
71
|
+
self.dismiss(text)
|
|
72
|
+
|
|
73
|
+
def action_close(self) -> None:
|
|
74
|
+
self.dismiss(None)
|
|
75
|
+
|
|
76
|
+
def _set_status(self, message: str) -> None:
|
|
77
|
+
if self._status is None:
|
|
78
|
+
return
|
|
79
|
+
self._status.update(message)
|
|
80
|
+
if self._clear_timer:
|
|
81
|
+
self._clear_timer.stop()
|
|
82
|
+
self._clear_timer = None
|
|
83
|
+
if message:
|
|
84
|
+
self._clear_timer = self.set_timer(1.5, self._clear_status)
|
|
85
|
+
|
|
86
|
+
def _clear_status(self) -> None:
|
|
87
|
+
if self._status:
|
|
88
|
+
self._status.update("")
|
|
89
|
+
if self._clear_timer:
|
|
90
|
+
self._clear_timer.stop()
|
|
91
|
+
self._clear_timer = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TaskListItem(ListItem):
|
|
95
|
+
"""Visual row representing a task."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, task: TodoTask) -> None:
|
|
98
|
+
self._task_model = task
|
|
99
|
+
self._highlighted = False
|
|
100
|
+
classes = ["task_item"]
|
|
101
|
+
if task.completed:
|
|
102
|
+
classes.append("task_item--completed")
|
|
103
|
+
elif self._is_priority(task):
|
|
104
|
+
classes.append("task_item--priority")
|
|
105
|
+
|
|
106
|
+
self._text_widget = Label("", classes="task_item_text", markup=True)
|
|
107
|
+
self._meta_widget = Label("", classes="task_item_meta")
|
|
108
|
+
|
|
109
|
+
super().__init__(
|
|
110
|
+
Horizontal(
|
|
111
|
+
self._text_widget,
|
|
112
|
+
self._meta_widget,
|
|
113
|
+
),
|
|
114
|
+
classes=" ".join(classes),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def on_mount(self) -> None:
|
|
118
|
+
self._text_widget.update(
|
|
119
|
+
self._render_text_markup(self._task_model, highlighted=False)
|
|
120
|
+
)
|
|
121
|
+
self._meta_widget.update(self._render_meta(self._task_model))
|
|
122
|
+
|
|
123
|
+
def update_task(self, task: TodoTask) -> None:
|
|
124
|
+
self._task_model = task
|
|
125
|
+
self.set_class(task.completed, "task_item--completed")
|
|
126
|
+
self.set_class(
|
|
127
|
+
not task.completed and self._is_priority(task),
|
|
128
|
+
"task_item--priority",
|
|
129
|
+
)
|
|
130
|
+
self._text_widget.update(
|
|
131
|
+
self._render_text_markup(self._task_model, highlighted=self._highlighted)
|
|
132
|
+
)
|
|
133
|
+
self._meta_widget.update(self._render_meta(self._task_model))
|
|
134
|
+
|
|
135
|
+
def set_highlighted(self, highlighted: bool) -> None:
|
|
136
|
+
if self._highlighted == highlighted:
|
|
137
|
+
return
|
|
138
|
+
self._highlighted = highlighted
|
|
139
|
+
self._text_widget.update(
|
|
140
|
+
self._render_text_markup(self._task_model, highlighted=highlighted)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def task_model(self) -> TodoTask:
|
|
145
|
+
return self._task_model
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _is_priority(task: TodoTask) -> bool:
|
|
149
|
+
stripped = task.text.lstrip()
|
|
150
|
+
return stripped.startswith("!") or stripped.startswith("[!]")
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _render_text_markup(task: TodoTask, *, highlighted: bool) -> str:
|
|
154
|
+
tokens = task.text.split()
|
|
155
|
+
if not tokens:
|
|
156
|
+
return "[i dim](no text)[/]"
|
|
157
|
+
|
|
158
|
+
tag_color = "$text" if highlighted else "$primary"
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
for token in tokens:
|
|
161
|
+
safe = escape(token)
|
|
162
|
+
if token.startswith("@") and len(token) > 1:
|
|
163
|
+
parts.append(f"[i {tag_color}]{safe}[/]")
|
|
164
|
+
else:
|
|
165
|
+
parts.append(safe)
|
|
166
|
+
|
|
167
|
+
text = " ".join(parts)
|
|
168
|
+
if task.completed:
|
|
169
|
+
return f"[strike dim]{text}[/]"
|
|
170
|
+
if TaskListItem._is_priority(task):
|
|
171
|
+
return f"[bold]{text}[/]"
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _render_meta(task: TodoTask) -> str:
|
|
176
|
+
stamp = task.completed_at or task.created_at
|
|
177
|
+
label = "done" if task.completed else "added"
|
|
178
|
+
return f"{label} {TaskListItem._format_timestamp(stamp)}"
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _format_timestamp(stamp: datetime) -> str:
|
|
182
|
+
local = stamp.astimezone()
|
|
183
|
+
return local.strftime("%b %d %H:%M")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TaskListScreen(ModalScreen[None]):
|
|
187
|
+
"""Full-screen list of tasks with keyboard interactions."""
|
|
188
|
+
|
|
189
|
+
BINDINGS = [
|
|
190
|
+
Binding("escape", "close", "Close", show=True),
|
|
191
|
+
Binding("q", "close", "Close", show=False),
|
|
192
|
+
Binding("space", "toggle_task", "Toggle completion", show=True),
|
|
193
|
+
Binding("delete", "delete_task", "Delete task", show=True),
|
|
194
|
+
Binding("e", "edit_task", "Edit task", show=True),
|
|
195
|
+
Binding("t", "capture_task", "Add task", show=True),
|
|
196
|
+
Binding("j", "cursor_down", "Next", show=False),
|
|
197
|
+
Binding("k", "cursor_up", "Previous", show=False),
|
|
198
|
+
Binding("C", "clear_completed", "Clear completed", show=True),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def __init__(self, store: TaskStore, *, state_store: TaskListStateStore) -> None:
|
|
202
|
+
super().__init__()
|
|
203
|
+
self._store = store
|
|
204
|
+
self._state_store = state_store
|
|
205
|
+
self._state_subscription = self._handle_state_update
|
|
206
|
+
self._subscription_registered = False
|
|
207
|
+
self._refresh_timer: Timer | None = None
|
|
208
|
+
self._index_assignment_token = 0
|
|
209
|
+
self._filter_input: Input | None = None
|
|
210
|
+
self._pending_focus_list = True
|
|
211
|
+
|
|
212
|
+
def compose(self):
|
|
213
|
+
placeholder = ListItem(
|
|
214
|
+
LoadingIndicator(),
|
|
215
|
+
classes="task_item--loading",
|
|
216
|
+
)
|
|
217
|
+
placeholder.disabled = True
|
|
218
|
+
filter_input = Input(
|
|
219
|
+
id="task_filter_input",
|
|
220
|
+
placeholder="Filter by tags (e.g. @kenny, @cbs)",
|
|
221
|
+
)
|
|
222
|
+
self._filter_input = filter_input
|
|
223
|
+
yield Container(
|
|
224
|
+
Vertical(
|
|
225
|
+
Container(filter_input, id="task_filter_container"),
|
|
226
|
+
ListView(placeholder, id="task_list_view"),
|
|
227
|
+
Footer(id="task_list_footer"),
|
|
228
|
+
),
|
|
229
|
+
id="task_list_modal",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def on_mount(self) -> None:
|
|
233
|
+
list_view = self.query_one(ListView)
|
|
234
|
+
list_view.border_title = "Tasks"
|
|
235
|
+
list_view.focus()
|
|
236
|
+
if self._filter_input is None:
|
|
237
|
+
self._filter_input = self.query_one("#task_filter_input", Input)
|
|
238
|
+
self._state_store.subscribe(self._state_subscription)
|
|
239
|
+
if not self._subscription_registered:
|
|
240
|
+
self._store.subscribe(self._handle_store_update)
|
|
241
|
+
self._subscription_registered = True
|
|
242
|
+
|
|
243
|
+
def on_show(self) -> None:
|
|
244
|
+
if self._refresh_timer is not None:
|
|
245
|
+
self._refresh_timer.stop()
|
|
246
|
+
self._refresh_timer = None
|
|
247
|
+
self._refresh_task_list(focus_list=True)
|
|
248
|
+
|
|
249
|
+
def on_unmount(self) -> None:
|
|
250
|
+
if self._subscription_registered:
|
|
251
|
+
self._store.unsubscribe(self._handle_store_update)
|
|
252
|
+
self._subscription_registered = False
|
|
253
|
+
self._state_store.unsubscribe(self._state_subscription)
|
|
254
|
+
if self._refresh_timer is not None:
|
|
255
|
+
self._refresh_timer.stop()
|
|
256
|
+
self._refresh_timer = None
|
|
257
|
+
|
|
258
|
+
def _handle_store_update(self, _: Sequence[TodoTask]) -> None:
|
|
259
|
+
self._schedule_refresh()
|
|
260
|
+
|
|
261
|
+
def _schedule_refresh(
|
|
262
|
+
self, *, focus_list: bool = True, delay: float = 0.05
|
|
263
|
+
) -> None:
|
|
264
|
+
if self._refresh_timer is not None:
|
|
265
|
+
self._refresh_timer.stop()
|
|
266
|
+
self._refresh_timer = None
|
|
267
|
+
self._pending_focus_list = focus_list
|
|
268
|
+
self._refresh_timer = self.set_timer(
|
|
269
|
+
delay, self._run_scheduled_refresh, name="task-list-refresh"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _run_scheduled_refresh(self) -> None:
|
|
273
|
+
self._refresh_timer = None
|
|
274
|
+
self._refresh_task_list(focus_list=self._pending_focus_list)
|
|
275
|
+
|
|
276
|
+
def _refresh_task_list(self, *, focus_list: bool = True) -> None:
|
|
277
|
+
list_view = self.query_one(ListView)
|
|
278
|
+
|
|
279
|
+
tasks = self._apply_tag_filter(self._store.sorted())
|
|
280
|
+
if not tasks:
|
|
281
|
+
list_view.index = None
|
|
282
|
+
list_view.clear()
|
|
283
|
+
self._state_store.set_highlighted_task_id(None)
|
|
284
|
+
placeholder_message = "No tasks yet"
|
|
285
|
+
if self._state_store.state.active_tag_filter:
|
|
286
|
+
placeholder_message = "No tasks match selected tags"
|
|
287
|
+
placeholder = ListItem(
|
|
288
|
+
Label(placeholder_message, classes="task_item_text"),
|
|
289
|
+
classes="task_item task_item--empty",
|
|
290
|
+
)
|
|
291
|
+
placeholder.disabled = True
|
|
292
|
+
list_view.append(placeholder)
|
|
293
|
+
self._queue_index_assignment(list_view, None, focus_list=focus_list)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
reusable_items = self._reuse_items_if_possible(list_view, tasks)
|
|
297
|
+
if reusable_items is not None:
|
|
298
|
+
for item, task in zip(reusable_items, tasks):
|
|
299
|
+
item.update_task(task)
|
|
300
|
+
if focus_list:
|
|
301
|
+
list_view.focus()
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
list_view.index = None
|
|
305
|
+
list_view.clear()
|
|
306
|
+
self._state_store.set_highlighted_task_id(None)
|
|
307
|
+
for task in tasks:
|
|
308
|
+
list_view.append(TaskListItem(task))
|
|
309
|
+
self._queue_index_assignment(list_view, 0, focus_list=focus_list)
|
|
310
|
+
|
|
311
|
+
def _selected_task(self) -> TodoTask | None:
|
|
312
|
+
list_view = self.query_one(ListView)
|
|
313
|
+
item = list_view.highlighted_child
|
|
314
|
+
if isinstance(item, TaskListItem):
|
|
315
|
+
return item.task_model
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
@on(ListView.Highlighted, "#task_list_view")
|
|
319
|
+
def _handle_highlighted(self, event: ListView.Highlighted) -> None:
|
|
320
|
+
item = event.item
|
|
321
|
+
previous_id = self._state_store.state.highlighted_task_id
|
|
322
|
+
if isinstance(item, TaskListItem):
|
|
323
|
+
if previous_id and previous_id != item.task_model.id:
|
|
324
|
+
previous_item = self._find_task_item(previous_id)
|
|
325
|
+
if previous_item is not None:
|
|
326
|
+
previous_item.set_highlighted(False)
|
|
327
|
+
item.set_highlighted(True)
|
|
328
|
+
self._state_store.set_highlighted_task_id(item.task_model.id)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
if previous_id:
|
|
332
|
+
previous_item = self._find_task_item(previous_id)
|
|
333
|
+
if previous_item is not None:
|
|
334
|
+
previous_item.set_highlighted(False)
|
|
335
|
+
self._state_store.set_highlighted_task_id(None)
|
|
336
|
+
|
|
337
|
+
def _find_task_item(self, task_id: str) -> TaskListItem | None:
|
|
338
|
+
list_view = self.query_one(ListView)
|
|
339
|
+
for child in list_view.children:
|
|
340
|
+
if isinstance(child, TaskListItem) and child.task_model.id == task_id:
|
|
341
|
+
return child
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def _queue_index_assignment(
|
|
345
|
+
self, list_view: ListView, target: int | None, *, focus_list: bool = True
|
|
346
|
+
) -> None:
|
|
347
|
+
self._index_assignment_token += 1
|
|
348
|
+
token = self._index_assignment_token
|
|
349
|
+
|
|
350
|
+
def assign(idx=target, token=token) -> None:
|
|
351
|
+
if token != self._index_assignment_token:
|
|
352
|
+
return
|
|
353
|
+
self._apply_index(list_view, idx)
|
|
354
|
+
if focus_list:
|
|
355
|
+
list_view.focus()
|
|
356
|
+
|
|
357
|
+
list_view.call_after_refresh(assign)
|
|
358
|
+
|
|
359
|
+
def action_cursor_down(self) -> None:
|
|
360
|
+
list_view = self.query_one(ListView)
|
|
361
|
+
if not self._has_selectable_items(list_view):
|
|
362
|
+
return
|
|
363
|
+
list_view.action_cursor_down()
|
|
364
|
+
|
|
365
|
+
def action_cursor_up(self) -> None:
|
|
366
|
+
list_view = self.query_one(ListView)
|
|
367
|
+
if not self._has_selectable_items(list_view):
|
|
368
|
+
return
|
|
369
|
+
list_view.action_cursor_up()
|
|
370
|
+
|
|
371
|
+
def _apply_index(self, list_view: ListView, target: int | None) -> None:
|
|
372
|
+
children = list_view.children
|
|
373
|
+
if not children or target is None:
|
|
374
|
+
list_view.index = None
|
|
375
|
+
return
|
|
376
|
+
if not self._has_selectable_items(list_view):
|
|
377
|
+
list_view.index = None
|
|
378
|
+
return
|
|
379
|
+
clamped = max(0, min(target, len(children) - 1))
|
|
380
|
+
child = children[clamped]
|
|
381
|
+
if getattr(child, "disabled", False):
|
|
382
|
+
list_view.index = None
|
|
383
|
+
return
|
|
384
|
+
list_view.index = clamped
|
|
385
|
+
|
|
386
|
+
def _has_selectable_items(self, list_view: ListView) -> bool:
|
|
387
|
+
return any(
|
|
388
|
+
not getattr(child, "disabled", False) for child in list_view.children
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _reuse_items_if_possible(
|
|
392
|
+
self, list_view: ListView, tasks: list[TodoTask]
|
|
393
|
+
) -> list[TaskListItem] | None:
|
|
394
|
+
items: list[TaskListItem] = []
|
|
395
|
+
for child in list_view.children:
|
|
396
|
+
if not isinstance(child, TaskListItem):
|
|
397
|
+
return None
|
|
398
|
+
items.append(child)
|
|
399
|
+
if len(items) != len(tasks):
|
|
400
|
+
return None
|
|
401
|
+
if any(item.task_model.id != task.id for item, task in zip(items, tasks)):
|
|
402
|
+
return None
|
|
403
|
+
return items
|
|
404
|
+
|
|
405
|
+
def action_capture_task(self) -> None:
|
|
406
|
+
def handle_submit(text: str) -> None:
|
|
407
|
+
try:
|
|
408
|
+
self._store.add(text)
|
|
409
|
+
except ValueError:
|
|
410
|
+
self.app.bell()
|
|
411
|
+
|
|
412
|
+
self.app.push_screen(TaskCaptureModal(handle_submit))
|
|
413
|
+
|
|
414
|
+
def action_toggle_task(self) -> None:
|
|
415
|
+
task = self._selected_task()
|
|
416
|
+
if task:
|
|
417
|
+
self._store.toggle(task.id)
|
|
418
|
+
|
|
419
|
+
def action_delete_task(self) -> None:
|
|
420
|
+
task = self._selected_task()
|
|
421
|
+
if task:
|
|
422
|
+
self._store.delete(task.id)
|
|
423
|
+
|
|
424
|
+
def action_edit_task(self) -> None:
|
|
425
|
+
task = self._selected_task()
|
|
426
|
+
if not task:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
def after(result: str | None) -> None:
|
|
430
|
+
if result is None:
|
|
431
|
+
return
|
|
432
|
+
self._store.update_text(task.id, result)
|
|
433
|
+
|
|
434
|
+
self.app.push_screen(TaskEditModal(task.text), after)
|
|
435
|
+
|
|
436
|
+
def action_clear_completed(self) -> None:
|
|
437
|
+
if not any(task.completed for task in self._store.all()):
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
def after(choice: bool | None) -> None:
|
|
441
|
+
if choice:
|
|
442
|
+
self._store.clear_completed()
|
|
443
|
+
|
|
444
|
+
self.app.push_screen(ConfirmDialog("Clear all completed tasks?"), after)
|
|
445
|
+
|
|
446
|
+
def action_close(self) -> None:
|
|
447
|
+
self.dismiss(None)
|
|
448
|
+
|
|
449
|
+
def action_focus_filter(self) -> None:
|
|
450
|
+
field = self._filter_input or self.query_one("#task_filter_input", Input)
|
|
451
|
+
field.focus()
|
|
452
|
+
field.cursor_position = len(field.value)
|
|
453
|
+
|
|
454
|
+
def _apply_tag_filter(self, tasks: list[TodoTask]) -> list[TodoTask]:
|
|
455
|
+
if not self._state_store.state.active_tag_filter:
|
|
456
|
+
return tasks
|
|
457
|
+
return [task for task in tasks if self._task_matches_filter(task)]
|
|
458
|
+
|
|
459
|
+
def _task_matches_filter(self, task: TodoTask) -> bool:
|
|
460
|
+
task_tags = self._extract_tags(task.text)
|
|
461
|
+
for filter_tag in self._state_store.state.active_tag_filter:
|
|
462
|
+
if not self._tag_fuzzy_match(filter_tag, task_tags):
|
|
463
|
+
return False
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _tag_fuzzy_match(filter_tag: str, task_tags: set[str]) -> bool:
|
|
468
|
+
query = filter_tag.lower()
|
|
469
|
+
for tag in task_tags:
|
|
470
|
+
if query in tag:
|
|
471
|
+
return True
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _extract_tags(text: str) -> set[str]:
|
|
476
|
+
tags: set[str] = set()
|
|
477
|
+
for token in text.split():
|
|
478
|
+
if token.startswith("@") and len(token) > 1:
|
|
479
|
+
tags.add(token.lower())
|
|
480
|
+
return tags
|
|
481
|
+
|
|
482
|
+
@on(Input.Changed, "#task_filter_input")
|
|
483
|
+
def _handle_filter_changed(self, event: Input.Changed) -> None:
|
|
484
|
+
event.stop()
|
|
485
|
+
self._state_store.set_active_tag_filter(self._extract_tags(event.value))
|
|
486
|
+
self._schedule_refresh(focus_list=False, delay=0.15)
|
|
487
|
+
|
|
488
|
+
def _handle_state_update(self, state: TaskListState) -> None:
|
|
489
|
+
if self._filter_input is None:
|
|
490
|
+
return
|
|
491
|
+
if not state.active_tag_filter:
|
|
492
|
+
if self._filter_input.value:
|
|
493
|
+
self._filter_input.value = ""
|
ferp/widgets/top_bar.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Container, Horizontal
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Label
|
|
7
|
+
|
|
8
|
+
from ferp.core.state import AppState, AppStateStore
|
|
9
|
+
|
|
10
|
+
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TopBar(Container):
|
|
14
|
+
"""Custom application title bar."""
|
|
15
|
+
|
|
16
|
+
current_path = reactive("", always_update=True)
|
|
17
|
+
status = reactive("Ready", always_update=True)
|
|
18
|
+
cache_updated_at = reactive(
|
|
19
|
+
datetime(1970, 1, 1, tzinfo=timezone.utc), always_update=True
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
app_title: str | None,
|
|
26
|
+
app_version: str,
|
|
27
|
+
state_store: AppStateStore,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._state_store = state_store
|
|
31
|
+
self._state_subscription = self._handle_state_update
|
|
32
|
+
|
|
33
|
+
self.title_label = Horizontal(
|
|
34
|
+
Label(
|
|
35
|
+
f"{app_title}",
|
|
36
|
+
id="topbar_app_name",
|
|
37
|
+
),
|
|
38
|
+
Label(
|
|
39
|
+
f"v{app_version}",
|
|
40
|
+
id="topbar_app_version",
|
|
41
|
+
),
|
|
42
|
+
id="app_meta_container",
|
|
43
|
+
)
|
|
44
|
+
self.status_label = Label("", id="topbar_script_status")
|
|
45
|
+
self.cache_label = Label("", id="topbar_cache")
|
|
46
|
+
|
|
47
|
+
def on_mount(self) -> None:
|
|
48
|
+
self._state_store.subscribe(self._state_subscription)
|
|
49
|
+
|
|
50
|
+
def on_unmount(self) -> None:
|
|
51
|
+
self._state_store.unsubscribe(self._state_subscription)
|
|
52
|
+
|
|
53
|
+
def watch_current_path(self) -> None:
|
|
54
|
+
self._update_status()
|
|
55
|
+
|
|
56
|
+
def watch_status(self) -> None:
|
|
57
|
+
self._update_status()
|
|
58
|
+
|
|
59
|
+
def watch_cache_updated_at(self) -> None:
|
|
60
|
+
self._update_cache_status()
|
|
61
|
+
|
|
62
|
+
def _update_status(self) -> None:
|
|
63
|
+
if not self.current_path:
|
|
64
|
+
self.status_label.update("")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
status = {
|
|
68
|
+
"ready": f"[dim]Ready - [/dim]{self.current_path}",
|
|
69
|
+
"running": f"[$foreground]Running[/] - {self.current_path}",
|
|
70
|
+
}
|
|
71
|
+
self.status_label.update(
|
|
72
|
+
status["ready"] if self.status == "Ready" else status["running"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _update_cache_status(self) -> None:
|
|
76
|
+
if self.cache_updated_at == EPOCH:
|
|
77
|
+
self.cache_label.update("[dim]Cache: never updated[/dim]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
relative = self._format_relative_time(self.cache_updated_at)
|
|
81
|
+
self.cache_label.update(f"[dim]Cache updated:[/dim] {relative}")
|
|
82
|
+
|
|
83
|
+
def _format_relative_time(self, ts: datetime) -> str:
|
|
84
|
+
now = datetime.now(timezone.utc)
|
|
85
|
+
delta: timedelta = now - ts
|
|
86
|
+
|
|
87
|
+
seconds = int(delta.total_seconds())
|
|
88
|
+
|
|
89
|
+
if seconds < 30:
|
|
90
|
+
return "just now"
|
|
91
|
+
if seconds < 60:
|
|
92
|
+
return f"{seconds}s ago"
|
|
93
|
+
if seconds < 3600:
|
|
94
|
+
return f"{seconds // 60} min ago"
|
|
95
|
+
if seconds < 86400:
|
|
96
|
+
return f"{seconds // 3600} hr ago"
|
|
97
|
+
if seconds < 172800:
|
|
98
|
+
return "yesterday"
|
|
99
|
+
|
|
100
|
+
return f"{seconds // 86400} days ago"
|
|
101
|
+
|
|
102
|
+
def _handle_state_update(self, state: AppState) -> None:
|
|
103
|
+
self.current_path = state.current_path
|
|
104
|
+
self.status = state.status
|
|
105
|
+
self.cache_updated_at = state.cache_updated_at
|
|
106
|
+
|
|
107
|
+
def compose(self) -> ComposeResult:
|
|
108
|
+
yield self.title_label
|
|
109
|
+
yield self.status_label
|
|
110
|
+
yield self.cache_label
|