tasko 0.0.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.
@@ -0,0 +1,342 @@
1
+ """Reusable modal dialogs: one-line text input, yes/no confirmation, option picking, and key help."""
2
+
3
+ from importlib.metadata import version
4
+ from typing import TYPE_CHECKING, ClassVar, Final
5
+
6
+ from rich.style import Style
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from textual.binding import Binding, BindingType
10
+ from textual.containers import Vertical
11
+ from textual.fuzzy import FuzzySearch
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Input, Label, OptionList, Static, TextArea
14
+ from textual.widgets.option_list import Option
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ from textual.app import ComposeResult
20
+
21
+ _MATCH_STYLE: Final = Style(bold=True, underline=True) # characters of a label the fuzzy query matched
22
+
23
+
24
+ class Dialog[ResultT](ModalScreen[ResultT]):
25
+ """Base modal dialog: a centered bordered panel over the dimmed screen; Escape cancels.
26
+
27
+ Subclasses define action_cancel with their own "cancelled" result.
28
+ """
29
+
30
+ DEFAULT_CSS = """
31
+ Dialog {
32
+ align: center middle;
33
+ }
34
+ Dialog > Vertical {
35
+ width: 60;
36
+ height: auto;
37
+ padding: 1 2;
38
+ border: round $primary;
39
+ background: $surface;
40
+ }
41
+ Dialog Input {
42
+ margin: 1 0 0 0;
43
+ background: $boost;
44
+ }
45
+ """
46
+
47
+ BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]
48
+
49
+
50
+ class InputDialog(Dialog[str | None]):
51
+ """Prompt for one line of text; dismisses with the value, or None on cancel."""
52
+
53
+ # Wider than the base panel: one-line values (task titles) routinely outgrow 60 cells.
54
+ DEFAULT_CSS = """
55
+ InputDialog > Vertical {
56
+ width: 80%;
57
+ }
58
+ """
59
+
60
+ def __init__(self, title: str, value: str = "") -> None:
61
+ """Set the prompt title and the optional pre-filled value (used by rename)."""
62
+ super().__init__()
63
+ self._title = title # prompt shown above the input
64
+ self._value = value # initial input content
65
+
66
+ def compose(self) -> ComposeResult:
67
+ """Render the prompt: label + input."""
68
+ with Vertical():
69
+ yield Label(self._title)
70
+ yield Input(value=self._value, compact=True)
71
+
72
+ def on_mount(self) -> None:
73
+ """Focus the input so typing starts immediately."""
74
+ self.query_one(Input).focus()
75
+
76
+ def on_input_submitted(self, event: Input.Submitted) -> None:
77
+ """Enter accepts the value."""
78
+ self.dismiss(event.value)
79
+
80
+ def action_cancel(self) -> None:
81
+ """Escape cancels without a value."""
82
+ self.dismiss(None)
83
+
84
+
85
+ class TextDialog(Dialog[str | None]):
86
+ """Edit multi-line text; dismisses with the text on Ctrl+S, or None on cancel.
87
+
88
+ Enter inserts a newline inside the TextArea, so saving lives on Ctrl+S.
89
+ """
90
+
91
+ # Nearly full-screen: this edits prose, not a one-liner.
92
+ DEFAULT_CSS = """
93
+ TextDialog > Vertical {
94
+ width: 90%;
95
+ height: 90%;
96
+ }
97
+ TextDialog TextArea {
98
+ margin: 1 0;
99
+ height: 1fr;
100
+ background: $boost;
101
+ border: none;
102
+ padding: 0 1;
103
+ }
104
+ """
105
+
106
+ BINDINGS: ClassVar[list[BindingType]] = [
107
+ ("ctrl+s", "save", "Save"),
108
+ # priority: the TextArea itself binds ctrl+a to "cursor to line start"; the GUI
109
+ # habit (select all) wins here, line start stays available on Home.
110
+ Binding("ctrl+a", "select_all", "Select all", priority=True),
111
+ ]
112
+
113
+ def __init__(self, title: str, text: str = "") -> None:
114
+ """Set the prompt title and the initial text."""
115
+ super().__init__()
116
+ self._title = title # prompt shown above the editor
117
+ self._text = text # initial editor content
118
+
119
+ def compose(self) -> ComposeResult:
120
+ """Render the prompt: label + editor + key hint."""
121
+ with Vertical():
122
+ yield Label(self._title)
123
+ yield TextArea(self._text)
124
+ yield Label("[b]ctrl+s[/b] — save [b]ctrl+a[/b] — select all [b]esc[/b] — cancel")
125
+
126
+ def on_mount(self) -> None:
127
+ """Focus the editor so typing starts immediately."""
128
+ self.query_one(TextArea).focus()
129
+
130
+ def action_save(self) -> None:
131
+ """Ctrl+S accepts the text."""
132
+ self.dismiss(self.query_one(TextArea).text)
133
+
134
+ def action_select_all(self) -> None:
135
+ """Ctrl+A selects the whole text, e.g. to replace or delete a long body at once."""
136
+ self.query_one(TextArea).select_all()
137
+
138
+ def action_cancel(self) -> None:
139
+ """Escape cancels without a value."""
140
+ self.dismiss(None)
141
+
142
+
143
+ class ConfirmDialog(Dialog[bool]):
144
+ """Ask a yes/no question; dismisses with True only on explicit confirmation."""
145
+
146
+ # Only the accent differs from the base panel: warning border for a destructive question.
147
+ DEFAULT_CSS = """
148
+ ConfirmDialog > Vertical {
149
+ border: round $warning;
150
+ }
151
+ """
152
+
153
+ BINDINGS: ClassVar[list[BindingType]] = [
154
+ ("y", "confirm", "Yes"),
155
+ ("n", "cancel", "No"),
156
+ ]
157
+
158
+ def __init__(self, question: str) -> None:
159
+ """Set the question text."""
160
+ super().__init__()
161
+ self._question = question # what the user is asked to confirm
162
+
163
+ def compose(self) -> ComposeResult:
164
+ """Render the question and the key hint."""
165
+ with Vertical():
166
+ yield Label(self._question)
167
+ yield Label("[b]y[/b] — yes [b]n[/b]/[b]esc[/b] — no")
168
+
169
+ def action_confirm(self) -> None:
170
+ """Confirm the action."""
171
+ self.dismiss(True)
172
+
173
+ def action_cancel(self) -> None:
174
+ """Cancel the action."""
175
+ self.dismiss(False)
176
+
177
+
178
+ class HelpDialog(Dialog[None]):
179
+ """Show the current screen's keys as a two-column list; Escape, ? or i closes it."""
180
+
181
+ # The about line sits under the keys, quiet and centered.
182
+ DEFAULT_CSS = """
183
+ HelpDialog #about {
184
+ margin-top: 1;
185
+ width: 100%;
186
+ text-align: center;
187
+ color: $text-muted;
188
+ }
189
+ """
190
+
191
+ BINDINGS: ClassVar[list[BindingType]] = [("question_mark,i", "cancel", "Close")]
192
+
193
+ def __init__(self, rows: list[tuple[str, str]]) -> None:
194
+ """Set the (key, description) rows in display order."""
195
+ super().__init__()
196
+ self._rows = rows # curated per screen; see the HELP_KEYS attribute on screens
197
+
198
+ def compose(self) -> ComposeResult:
199
+ """Render the title, the key table, and the app name with its version."""
200
+ grid = Table.grid(padding=(0, 2))
201
+ grid.add_column(justify="right", style="bold")
202
+ grid.add_column()
203
+ for key, description in self._rows:
204
+ grid.add_row(key, description)
205
+ with Vertical():
206
+ yield Label("Keys")
207
+ yield Static(grid, id="keys")
208
+ yield Label(f"tasko v{version('tasko')}", id="about")
209
+
210
+ def action_cancel(self) -> None:
211
+ """Close the help."""
212
+ self.dismiss(None)
213
+
214
+
215
+ class SelectDialog(Dialog[str | None]):
216
+ """Pick one option with arrows and Enter; dismisses with the option id, or None on cancel.
217
+
218
+ With `filterable` (the default) a query input keeps the focus the whole time:
219
+ printable keys narrow the list fuzzily, up/down move the highlight. Pass
220
+ `filterable=False` for short fixed sets where a filter is only noise.
221
+ """
222
+
223
+ DEFAULT_CSS = """
224
+ SelectDialog OptionList {
225
+ margin-top: 1;
226
+ height: auto;
227
+ max-height: 20;
228
+ border: none;
229
+ padding: 0;
230
+ background: transparent;
231
+ }
232
+ """
233
+
234
+ BINDINGS: ClassVar[list[BindingType]] = [
235
+ ("up", "cursor_up", "Up"),
236
+ ("down", "cursor_down", "Down"),
237
+ ("pageup", "page_up", "Page up"),
238
+ ("pagedown", "page_down", "Page down"),
239
+ # priority: Input binds home/end to text-cursor moves; with a short filter
240
+ # query, jumping the list matters more. ctrl+a/ctrl+e still edit the query.
241
+ Binding("home", "first", "First", priority=True),
242
+ Binding("end", "last", "Last", priority=True),
243
+ ]
244
+
245
+ def __init__(
246
+ self, title: str, options: Sequence[tuple[str, str | Text]], current: str | None = None, *, filterable: bool = True
247
+ ) -> None:
248
+ """Set the prompt title, the (id, label) options in display order, and the id the cursor starts on.
249
+
250
+ A label may come pre-styled as a Text (e.g. a meta-option rendered apart from
251
+ the real entries); its plain text drives the fuzzy filter.
252
+ """
253
+ super().__init__()
254
+ self._title = title # prompt shown above the list
255
+ # (id, label) pairs; ids must be unique. Labels normalize to Text so styled and plain ones flow the same.
256
+ self._options = [(option_id, label if isinstance(label, Text) else Text(label)) for option_id, label in options]
257
+ self._current = current # option id highlighted initially, None for the first one
258
+ self._filterable = filterable # False renders no query input; the list itself takes the focus
259
+
260
+ def compose(self) -> ComposeResult:
261
+ """Render the prompt: label + optional fuzzy query input + option list."""
262
+ with Vertical():
263
+ yield Label(self._title)
264
+ if self._filterable:
265
+ yield Input(placeholder="type to filter", compact=True)
266
+ yield OptionList(*[Option(label, id=option_id) for option_id, label in self._options])
267
+
268
+ def on_mount(self) -> None:
269
+ """Focus the input (or the list itself) and start the list cursor on the current option."""
270
+ option_list = self.query_one(OptionList)
271
+ if self._filterable:
272
+ self.query_one(Input).focus()
273
+ else:
274
+ option_list.focus()
275
+ if self._current is not None:
276
+ option_list.highlighted = option_list.get_option_index(self._current)
277
+
278
+ def on_input_changed(self, event: Input.Changed) -> None:
279
+ """Refill the list with fuzzy matches, best first; an empty query shows everything."""
280
+ query = event.value
281
+ option_list = self.query_one(OptionList)
282
+ options = self._options
283
+ if query:
284
+ fuzzy = FuzzySearch()
285
+ # One match() call per option yields both the score and the matched offsets.
286
+ # Ties keep the original order (sort is stable).
287
+ matches = [(fuzzy.match(query, label.plain), option_id, label) for option_id, label in options]
288
+ matches.sort(key=lambda m: -m[0][0])
289
+ options = []
290
+ for (score, offsets), option_id, label in matches:
291
+ if score <= 0:
292
+ break # sorted best-first: everything from here on is unmatched
293
+ # Underline the matched characters on a copy — stylizing in place would pile up across refilters.
294
+ prompt = label.copy()
295
+ for offset in offsets:
296
+ prompt.stylize(_MATCH_STYLE, offset, offset + 1)
297
+ options.append((option_id, prompt))
298
+ option_list.clear_options()
299
+ for option_id, label in options:
300
+ option_list.add_option(Option(label, id=option_id))
301
+ if option_list.option_count:
302
+ option_list.highlighted = 0
303
+
304
+ def on_input_submitted(self) -> None:
305
+ """Enter accepts the highlighted option; with no matches it does nothing."""
306
+ option_list = self.query_one(OptionList)
307
+ if option_list.highlighted is not None:
308
+ self.dismiss(option_list.get_option_at_index(option_list.highlighted).id)
309
+
310
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
311
+ """Mouse click — or Enter on the focused list — accepts an option directly."""
312
+ self.dismiss(event.option_id)
313
+
314
+ def action_cursor_up(self) -> None:
315
+ """Move the list highlight up without leaving the input."""
316
+ self.query_one(OptionList).action_cursor_up()
317
+
318
+ def action_cursor_down(self) -> None:
319
+ """Move the list highlight down without leaving the input."""
320
+ self.query_one(OptionList).action_cursor_down()
321
+
322
+ def action_page_up(self) -> None:
323
+ """Move the list highlight one page up without leaving the input."""
324
+ # textual's OptionList.action_page_up lacks a return annotation; the call is safe
325
+ self.query_one(OptionList).action_page_up() # type: ignore[no-untyped-call]
326
+
327
+ def action_page_down(self) -> None:
328
+ """Move the list highlight one page down without leaving the input."""
329
+ # textual's OptionList.action_page_down lacks a return annotation; the call is safe
330
+ self.query_one(OptionList).action_page_down() # type: ignore[no-untyped-call]
331
+
332
+ def action_first(self) -> None:
333
+ """Jump the list highlight to the first option without leaving the input."""
334
+ self.query_one(OptionList).action_first()
335
+
336
+ def action_last(self) -> None:
337
+ """Jump the list highlight to the last option without leaving the input."""
338
+ self.query_one(OptionList).action_last()
339
+
340
+ def action_cancel(self) -> None:
341
+ """Escape cancels without a choice."""
342
+ self.dismiss(None)
@@ -0,0 +1,160 @@
1
+ """Projects screen: a table of projects with management actions."""
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Final
4
+
5
+ from rich.text import Text
6
+ from textual.screen import Screen
7
+ from textual.widgets import DataTable
8
+
9
+ from tasko.core.errors import AppError
10
+ from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog
11
+ from tasko.tui.widgets import StatusBar
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from textual.app import ComposeResult
17
+ from textual.binding import BindingType
18
+
19
+ from tasko.core.core import Core
20
+
21
+ _DEFAULT_CELL: Final = Text("default", style="green") # marks the default project; space is not scarce here
22
+
23
+
24
+ class ProjectsScreen(Screen[None]):
25
+ """Project management — rarely visited, reached from the task screen with P."""
26
+
27
+ BINDINGS: ClassVar[list[BindingType]] = [
28
+ ("j", "cursor_down", "Down"),
29
+ ("k", "cursor_up", "Up"),
30
+ ("a", "add", "Add"),
31
+ ("e", "rename", "Rename"),
32
+ ("x", "delete", "Delete"),
33
+ ("m", "make_default", "Make default"),
34
+ ("escape", "app.pop_screen", "Back"),
35
+ ]
36
+
37
+ # What ?/i shows: the user-facing key reference for this screen, in reading order.
38
+ HELP_KEYS: ClassVar[list[tuple[str, str]]] = [
39
+ ("↑ ↓ j k", "move the cursor"),
40
+ ("a", "create a project"),
41
+ ("e", "rename the project under the cursor"),
42
+ ("x", "delete the project and all its tasks"),
43
+ ("m", "make it the default for quick capture"),
44
+ ("esc", "back to the task list"),
45
+ ("? i", "this help"),
46
+ ]
47
+
48
+ def __init__(self, core: Core) -> None:
49
+ """Wire the screen to the core."""
50
+ super().__init__()
51
+ self._core = core # core operations
52
+
53
+ def compose(self) -> ComposeResult:
54
+ """Render the project table and the status bar."""
55
+ yield DataTable()
56
+ yield StatusBar()
57
+
58
+ def on_mount(self) -> None:
59
+ """Configure and fill the table."""
60
+ table = self.query_one(DataTable)
61
+ table.cursor_type = "row" # actions apply to the row under the cursor
62
+ table.add_columns("ID", "Name", "Default", "Tasks")
63
+ self.reload()
64
+
65
+ def reload(self) -> None:
66
+ """Refill the table from the database, keeping the cursor on the same project when possible."""
67
+ table = self.query_one(DataTable)
68
+ previous_row = table.cursor_row
69
+ previous_id = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value if table.row_count else None
70
+ table.clear()
71
+ counts = self._core.project_task_counts()
72
+ default_id = self._core.app_state().default_project_id
73
+ projects = self._core.list_projects()
74
+ self.query_one(StatusBar).set_context(Text.assemble("projects", (f" · {len(projects)}", "dim")))
75
+ ids = []
76
+ for project in projects:
77
+ ids.append(str(project.id))
78
+ open_, total = counts.get(project.id, (0, 0))
79
+ table.add_row(
80
+ str(project.id),
81
+ project.name,
82
+ _DEFAULT_CELL if project.id == default_id else "",
83
+ f"{open_}/{total}", # open = todo + doing
84
+ key=str(project.id),
85
+ )
86
+ if table.row_count:
87
+ # Follow the project by id (rows reorder on rename); if it is gone, clamp the old position.
88
+ row = ids.index(previous_id) if previous_id in ids else min(previous_row, table.row_count - 1)
89
+ table.move_cursor(row=row)
90
+
91
+ def _cursor_project_id(self) -> int | None:
92
+ """Return the id of the project under the cursor, or None on an empty table.
93
+
94
+ The id comes from the row key, never from cell content — cells are display
95
+ and their order is free to change.
96
+ """
97
+ table = self.query_one(DataTable)
98
+ if table.row_count == 0:
99
+ return None
100
+ key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
101
+ return int(key) if key is not None else None
102
+
103
+ def _apply(self, operation: Callable[[], object]) -> None:
104
+ """Run a core operation; refresh the table on success, toast the message on AppError."""
105
+ try:
106
+ operation()
107
+ except AppError as e:
108
+ self.notify(str(e), severity="error")
109
+ else:
110
+ self.reload()
111
+
112
+ def action_cursor_down(self) -> None:
113
+ """Move the cursor down — the vim-trained alias for the down arrow."""
114
+ self.query_one(DataTable).action_cursor_down()
115
+
116
+ def action_cursor_up(self) -> None:
117
+ """Move the cursor up — the vim-trained alias for the up arrow."""
118
+ self.query_one(DataTable).action_cursor_up()
119
+
120
+ def action_add(self) -> None:
121
+ """Prompt for a name and create a project."""
122
+
123
+ def on_result(name: str | None) -> None:
124
+ if name:
125
+ self._apply(lambda: self._core.create_project(name))
126
+
127
+ self.app.push_screen(InputDialog("New project name:"), on_result)
128
+
129
+ def action_rename(self) -> None:
130
+ """Prompt for a new name for the project under the cursor."""
131
+ project_id = self._cursor_project_id()
132
+ if project_id is None:
133
+ return
134
+ name = self._core.get_project(project_id).name
135
+
136
+ def on_result(new_name: str | None) -> None:
137
+ if new_name:
138
+ self._apply(lambda: self._core.rename_project(project_id, new_name))
139
+
140
+ self.app.push_screen(InputDialog("Rename project:", value=name), on_result)
141
+
142
+ def action_delete(self) -> None:
143
+ """Confirm and delete the project under the cursor together with its tasks."""
144
+ project_id = self._cursor_project_id()
145
+ if project_id is None:
146
+ return
147
+ name = self._core.get_project(project_id).name
148
+
149
+ def on_result(confirmed: bool | None) -> None:
150
+ if confirmed:
151
+ self._apply(lambda: self._core.delete_project(project_id))
152
+
153
+ self.app.push_screen(ConfirmDialog(f"Delete project '{name}' and all its tasks?"), on_result)
154
+
155
+ def action_make_default(self) -> None:
156
+ """Make the project under the cursor the default one."""
157
+ project_id = self._cursor_project_id()
158
+ if project_id is None:
159
+ return
160
+ self._apply(lambda: self._core.set_default_project(project_id))