termcore 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 (55) hide show
  1. termcore/__init__.py +6 -0
  2. termcore/cli/__init__.py +3 -0
  3. termcore/cli/output.py +70 -0
  4. termcore/io/__init__.py +7 -0
  5. termcore/io/app_state_storage.py +185 -0
  6. termcore/io/database.py +655 -0
  7. termcore/io/errors.py +71 -0
  8. termcore/io/file.py +263 -0
  9. termcore/io/textfile.py +92 -0
  10. termcore/py.typed +0 -0
  11. termcore/tui/__init__.py +9 -0
  12. termcore/tui/custom_bindings.py +551 -0
  13. termcore/tui/custom_widgets/__init__.py +1 -0
  14. termcore/tui/custom_widgets/custom_checkbox.py +41 -0
  15. termcore/tui/custom_widgets/custom_data_table.py +247 -0
  16. termcore/tui/custom_widgets/custom_selection.py +80 -0
  17. termcore/tui/custom_widgets/multiline_footer.py +240 -0
  18. termcore/tui/question_screen.py +107 -0
  19. termcore/tui/theme_loader.py +495 -0
  20. termcore/tui/themes/__init__.py +2 -0
  21. termcore/tui/themes/classic-black-saturated/style.css +57 -0
  22. termcore/tui/themes/classic-black-saturated/theme.py +22 -0
  23. termcore/tui/themes/classic-black-v1/style.css +57 -0
  24. termcore/tui/themes/classic-black-v1/theme.py +22 -0
  25. termcore/tui/themes/classic-black-v2/style.css +57 -0
  26. termcore/tui/themes/classic-black-v2/theme.py +24 -0
  27. termcore/tui/themes/classic-blue/theme.py +21 -0
  28. termcore/tui/themes/compact-gray/style.tcss +175 -0
  29. termcore/tui/themes/compact-gray/theme.py +22 -0
  30. termcore/tui/themes/mnml-black/style.css +57 -0
  31. termcore/tui/themes/mnml-black/theme.py +21 -0
  32. termcore/tui/themes/mnml-deepblack/style.css +57 -0
  33. termcore/tui/themes/mnml-deepblack/theme.py +21 -0
  34. termcore/tui/themes/pure-amber/theme.py +18 -0
  35. termcore/tui/themes/pure-black/theme.py +18 -0
  36. termcore/tui/themes/pure-blue/theme.py +18 -0
  37. termcore/tui/themes/pure-green/theme.py +18 -0
  38. termcore/tui/themes/pure-sweet16/theme.py +20 -0
  39. termcore/tui/themes/xplore-black/theme.py +18 -0
  40. termcore/tui/themes/xplore-blue/theme.py +18 -0
  41. termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
  42. termcore/tui/themes/xplore-teal/theme.py +18 -0
  43. termcore/util/__init__.py +10 -0
  44. termcore/util/datetime.py +173 -0
  45. termcore/util/debug.py +196 -0
  46. termcore/util/index.py +112 -0
  47. termcore/util/logger.py +36 -0
  48. termcore/util/singleton.py +46 -0
  49. termcore/util/string.py +263 -0
  50. termcore/util/validation.py +31 -0
  51. termcore/util/version.py +56 -0
  52. termcore-0.2.0.dist-info/METADATA +504 -0
  53. termcore-0.2.0.dist-info/RECORD +55 -0
  54. termcore-0.2.0.dist-info/WHEEL +4 -0
  55. termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,247 @@
1
+ """
2
+ Extension of Textual's DataTable with support for flexible column resizing.
3
+
4
+ This module defines `CustomDataTable`, a subclass of Textual's `DataTable`
5
+ with additional capabilities for handling dynamic resizing of table columns
6
+ based on available space.
7
+
8
+ The primary enhancement is the introduction of *flexible columns* - a list of
9
+ column keys that automatically adjust their widths when the table is resized.
10
+ This allows developers to create responsive and adaptive data tables where
11
+ certain columns expand or contract depending on the widget size, while others
12
+ remain fixed.
13
+
14
+ The class overrides the `on_resize` method to calculate the total available
15
+ width and distribute it proportionally among the flexible columns. It also
16
+ provides helper methods for determining fixed column widths and adjusting
17
+ layout accordingly.
18
+
19
+ This is particularly useful for applications with tabular data in dynamic
20
+ layouts or where users resize windows and expect columns to adapt gracefully.
21
+ """
22
+ from typing import Any, TypeVar
23
+
24
+ from textual.coordinate import Coordinate
25
+ from textual.message import Message
26
+ from textual.widget import Widget
27
+ from textual.widgets import DataTable
28
+ from textual.widgets._data_table import Column, ColumnKey
29
+
30
+ __all__ = [
31
+ "CustomDataTable",
32
+ ]
33
+
34
+ _CellType = TypeVar("_CellType")
35
+
36
+
37
+ class CustomDataTable(DataTable[_CellType]):
38
+ """
39
+ A DataTable with columns that stretch to fill the available width.
40
+
41
+ Attributes
42
+ ----------
43
+ MIN_FLEX_COL_WIDTH : int
44
+ Minimum width for flexible columns.
45
+ flexible_columns : list[ColumnKey]
46
+ List of column keys that should be flexible in width
47
+ (will be adjusted according to window width).
48
+ """
49
+
50
+ MIN_FLEX_COL_WIDTH = 5
51
+ flexible_columns: list[ColumnKey]
52
+
53
+
54
+ class Mounted(Message):
55
+ """Posted once the table has been mounted and can be filled."""
56
+
57
+ def __init__(self, sender: Widget) -> None:
58
+ """Records which widget was mounted."""
59
+ super().__init__()
60
+ self.sender = sender
61
+
62
+
63
+ def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
64
+ """
65
+ Initializes the table with an empty set of flexible columns.
66
+
67
+ The arguments are whatever `DataTable` takes, which is what Any
68
+ says here; naming them would mean restating fifteen parameters
69
+ and re-stating them again on every Textual release.
70
+ """
71
+ super().__init__(**kwargs)
72
+
73
+ # Per instance: as a class attribute every table in the process
74
+ # shared one list and inherited the columns of all the others.
75
+ self.flexible_columns = []
76
+
77
+
78
+ def on_mount(self) -> None:
79
+ """
80
+ Handles the mount event of the DataTable.
81
+
82
+ This method is called when the DataTable is mounted in the application.
83
+ It posts a Mounted message to notify that the DataTable is ready.
84
+ This is useful for initializing any additional functionality that
85
+ depends on the DataTable being fully mounted.
86
+
87
+ Examples
88
+ --------
89
+ >>> @on(CustomDataTable.Mounted)
90
+ >>> async def on_table_mounted(self, event: CustomDataTable.Mounted):
91
+ ... # Add columns and rows to the table
92
+ ... pass
93
+ """
94
+ self.post_message(self.Mounted(self))
95
+
96
+ def on_resize(self) -> None:
97
+ """
98
+ Handles the resize event of the DataTable.
99
+
100
+ Adjusts the widths of the flexible columns based on the new size of
101
+ the table.
102
+ """
103
+ scrollbar_width = 2 if self.show_vertical_scrollbar else 0
104
+ table_width = self.size.width - len(self.columns) * 2 - scrollbar_width
105
+ fixed_widths = self.get_fixed_column_widths()
106
+ self.adjust_flexible_columns(table_width, fixed_widths)
107
+ self.refresh()
108
+
109
+ def get_fixed_column_widths(self) -> int:
110
+ """
111
+ Returns the total width of all fixed-width columns in the table.
112
+
113
+ This is used to calculate the available width for flexible columns.
114
+
115
+ Returns
116
+ -------
117
+ int
118
+ The total width of all fixed-width columns.
119
+ """
120
+ fixed_widths = 0
121
+
122
+ for column_key in self.columns:
123
+ column: Column = self.columns[column_key]
124
+
125
+ if column_key not in self.flexible_columns:
126
+ fixed_widths += column.width
127
+
128
+ return fixed_widths
129
+
130
+ def adjust_flexible_columns(self, table_width: int, fixed_width: int) \
131
+ -> None:
132
+ """
133
+ Adjusts the flexible columns to the width the table has.
134
+
135
+ Parameters
136
+ ----------
137
+ table_width : int
138
+ The total width of the table.
139
+ fixed_width : int
140
+ The total width of all fixed-width columns.
141
+ """
142
+ for column_key in self.columns:
143
+ column: Column = self.columns[column_key]
144
+
145
+ if column_key in self.flexible_columns:
146
+ column.auto_width = False
147
+ column.width = int(
148
+ (table_width - fixed_width) / len(self.flexible_columns)
149
+ )
150
+
151
+ # Don't allow column width to be less than MIN_FLEX_COL_WIDTH
152
+ column.width = max(column.width, self.MIN_FLEX_COL_WIDTH)
153
+
154
+ self.update_virtual_size()
155
+
156
+ def update_virtual_size(self) -> None:
157
+ """
158
+ Updates the virtual size from the current column widths.
159
+
160
+ This is what makes the horizontal scrollbar appear only when it is
161
+ actually needed.
162
+
163
+ The effect of this method is that the DataTable has the correct width
164
+ (no hidden column at the right side "compensating" the reduced widths
165
+ of the flexible columns) and that the horizontal scrollbar only appears
166
+ when the total width of all columns exceeds the visible width of
167
+ the DataTable widget.
168
+
169
+ The actual total width of all columns including separators is
170
+ calculated by the following formula:
171
+
172
+ ```
173
+ total_width = sum(column_widths) + (num_columns * 2 - 2)
174
+ ```
175
+
176
+ Notes
177
+ -----
178
+ Explanation:
179
+
180
+ - Each column has 2 pixels of spacing (padding/border)
181
+ - With n columns, there are (n-1) separators between columns
182
+ - The first column has left padding and the last has right padding
183
+ - This results in: `2 * (n-1) + 2 = 2n - 2 + 2 = 2n` pixels total
184
+ - However, empirically it's necessary to subtract 2 pixels to
185
+ account for the table's own border/padding, giving: `2n - 2`
186
+
187
+ Example with 4 columns: `4 * 2 - 2 = 6` pixels for separators
188
+ """
189
+ # Calculate total width of all columns including separators
190
+ total_column_width = sum(col.width for col in self.columns.values())
191
+ total_width_with_separators = total_column_width \
192
+ + (len(self.columns) * 2 - 2)
193
+
194
+ # Update virtual size of the DataTable to reflect new width
195
+ self.virtual_size = self.virtual_size._replace(
196
+ width=total_width_with_separators
197
+ )
198
+
199
+ def update_scrollbar_visibility(self) -> None:
200
+ """
201
+ Shows or hides the horizontal scrollbar based on the content width.
202
+
203
+ .. deprecated::
204
+ This method is no longer needed.
205
+
206
+ Scrollbar visibility is now automatically managed by the
207
+ `update_virtual_size()` method, which correctly sets the virtual
208
+ size of the table. The horizontal scrollbar will only appear when
209
+ the content width exceeds the table width.
210
+
211
+ Notes
212
+ -----
213
+ TODO: Remove this method in future versions.
214
+ """
215
+ total_content_width = sum(col.width for col in self.columns.values())
216
+
217
+ self.styles.scrollbar_size_horizontal = (
218
+ None if total_content_width > self.size.width else 0
219
+ )
220
+
221
+ def select_first_row(self) -> None:
222
+ """Selects the first row and posts a RowHighlighted event."""
223
+ if self.row_count == 0:
224
+ return
225
+
226
+ # Set cursor to first row
227
+ self.cursor_coordinate = Coordinate(0, 0)
228
+ self.move_cursor(row=0, column=0) # Same as above, just to be sure
229
+
230
+ # Manually post RowHighlighted event
231
+ row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate)
232
+ self.post_message(
233
+ DataTable.RowHighlighted(self, self.cursor_row, row_key)
234
+ )
235
+
236
+ def delete_selected_row(self) -> None:
237
+ """Deletes the currently selected row from the table."""
238
+ if self.row_count > 0:
239
+ row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate)
240
+ self.remove_row(row_key)
241
+
242
+ # def get_current_id(self) -> int:
243
+ # Returns the ID of the currently selected topic.
244
+
245
+ # Returns:
246
+ # int: The ID of the currently selected topic.
247
+
@@ -0,0 +1,80 @@
1
+ """A Textual SelectionList that shows check marks instead of X marks."""
2
+
3
+ from typing import TypeVar
4
+
5
+ from rich.segment import Segment
6
+ from textual.strip import Strip
7
+ from textual.widgets import SelectionList
8
+ from textual.widgets._toggle_button import ToggleButton
9
+
10
+ __all__ = [
11
+ "CustomSelectionList",
12
+ ]
13
+
14
+ _SelectionType = TypeVar("_SelectionType")
15
+
16
+
17
+ class CustomSelectionList(SelectionList[_SelectionType]):
18
+ """
19
+ A Textual SelectionList whose items show a check mark when selected.
20
+
21
+ If unselected, they show an empty box - instead of the default
22
+ behavior of showing an "X".
23
+
24
+ Attributes
25
+ ----------
26
+ CUSTOM_BUTTON_INNER : str
27
+ The character to display when an item is selected.
28
+ """
29
+
30
+ CUSTOM_BUTTON_INNER = "✔"
31
+
32
+
33
+ def render_line(self, y: int) -> Strip:
34
+ """
35
+ Renders one line, with check marks instead of X marks.
36
+
37
+ Parameters
38
+ ----------
39
+ y : int
40
+ The line number to render.
41
+
42
+ Returns
43
+ -------
44
+ Strip
45
+ A Strip representing the rendered line.
46
+ """
47
+ # Get the default rendered line from the parent class (SelectionList)
48
+ rendered_line: Strip = super().render_line(y)
49
+
50
+ # rendered_line contains the segments for the line including the
51
+ # check box representation. This segment needs to be modified to
52
+ # replace the default "X" with a custom check mark when selected.
53
+ # Since rendered_line is immutable, a new list of segments is created.
54
+ segments: list[Segment] = []
55
+
56
+ # Loop through each segment in the rendered line
57
+ for segment in rendered_line:
58
+ # Check if the segment text matches the default button inner
59
+ # (= the one that must be modified)
60
+ if segment.text == ToggleButton.BUTTON_INNER:
61
+ # Check if segment is selected
62
+ _, scroll_y = self.scroll_offset
63
+ selection_index = scroll_y + y
64
+ selection = self.get_option_at_index(selection_index)
65
+ if selection.value in self._selected:
66
+ segment_text = self.CUSTOM_BUTTON_INNER
67
+ else:
68
+ segment_text = " "
69
+
70
+ # Create a new segment with the custom check mark and same style
71
+ segment_style = segment.style
72
+ new_segment = Segment(segment_text, segment_style)
73
+ segments.append(new_segment)
74
+ else:
75
+ # Not the segment containing the check box -> keep segment as is
76
+ segments.append(segment)
77
+
78
+ return Strip([
79
+ *segments
80
+ ])
@@ -0,0 +1,240 @@
1
+ """
2
+ MultiLineFooter - a multi-line footer extending Textual's built-in Footer.
3
+
4
+ Two modes:
5
+
6
+ 1. auto_wrap=True → Bindings automatically wrap when the row is full.
7
+ 2. auto_wrap=False → Bindings are explicitly assigned to rows via `row_map`.
8
+
9
+ Usage:
10
+ # Auto-wrap (default)
11
+ yield MultiLineFooter()
12
+
13
+ # Manual row assignment
14
+ yield MultiLineFooter(
15
+ auto_wrap=False,
16
+ row_map={
17
+ "quit": 0,
18
+ "save": 0,
19
+ "toggle_dark": 1,
20
+ "help": 1,
21
+ },
22
+ )
23
+
24
+ The `row_map` maps binding actions to row numbers (0-based).
25
+ Unassigned bindings fall into the last defined row.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from collections import defaultdict
30
+ from typing import cast, override
31
+
32
+ from textual.app import App, ComposeResult
33
+ from textual.binding import Binding
34
+ from textual.containers import HorizontalGroup
35
+ from textual.reactive import reactive
36
+ from textual.widgets._footer import Footer, FooterKey
37
+
38
+ __all__ = [
39
+ "FooterRow",
40
+ "MultiLineFooter",
41
+ ]
42
+
43
+
44
+ class FooterRow(HorizontalGroup):
45
+ """Horizontal row containing FooterKey widgets."""
46
+
47
+ DEFAULT_CSS: str = """
48
+ FooterRow {
49
+ width: 1fr;
50
+ height: 1;
51
+ layout: horizontal;
52
+ background: $footer-background;
53
+ }
54
+ """
55
+
56
+
57
+ class MultiLineFooter(Footer):
58
+ """
59
+ Multi-line footer with auto-wrap or manual row assignment.
60
+
61
+ Inherits from Textual's built-in Footer and overrides the layout to support
62
+ multiple rows.
63
+
64
+ Attributes
65
+ ----------
66
+ auto_wrap : bool, default True
67
+ If True, bindings wrap automatically based on terminal width.
68
+ row_map : dict[str, int] or None, optional
69
+ Dict mapping action names to row numbers (0-based).
70
+ Only relevant when ``auto_wrap=False``.
71
+ max_rows : int, default 0
72
+ Maximum number of rows for auto-wrap. 0 means unlimited.
73
+ show_command_palette : bool, default True
74
+ Show the command palette binding.
75
+ compact : bool, default False
76
+ Use compact styling with less whitespace.
77
+ """
78
+
79
+ DEFAULT_CSS: str = """
80
+ MultiLineFooter {
81
+ height: auto;
82
+ layout: vertical;
83
+ scrollbar-size: 0 0;
84
+ }
85
+ """
86
+
87
+ auto_wrap: reactive[bool] = reactive(default=True)
88
+ max_rows: reactive[int] = reactive(0)
89
+ _bindings_ready: bool
90
+
91
+ def __init__( # noqa: PLR0913 - mirrors Textual's Widget signature
92
+ self,
93
+ *,
94
+ auto_wrap: bool = True,
95
+ row_map: dict[str, int] | None = None,
96
+ max_rows: int = 0,
97
+ show_command_palette: bool = True,
98
+ compact: bool = False,
99
+ name: str | None = None,
100
+ id: str | None = None, # noqa: A002 - Textual's parameter name
101
+ classes: str | None = None,
102
+ ) -> None:
103
+ super().__init__(
104
+ name=name,
105
+ id=id,
106
+ classes=classes,
107
+ show_command_palette=show_command_palette,
108
+ compact=compact,
109
+ )
110
+ self._row_map: dict[str, int] = row_map or {}
111
+ self.set_reactive(MultiLineFooter.auto_wrap, auto_wrap)
112
+ self.set_reactive(MultiLineFooter.max_rows, max_rows)
113
+
114
+ @staticmethod
115
+ def _estimate_key_width(key_display: str, description: str) -> int:
116
+ """Estimates the width of a FooterKey in columns (including padding)."""
117
+ w = 1 + len(key_display) + 1 # key padding
118
+ if description:
119
+ w += len(description) + 1 # description + right padding
120
+ return w
121
+
122
+ def _collect_bindings(self) -> list[tuple[Binding, bool, str]]:
123
+ """Collects active visible bindings, excluding the command palette."""
124
+ active = self.screen.active_bindings
125
+ app = cast("App[object]", self.app)
126
+ palette_key = (
127
+ app.COMMAND_PALETTE_BINDING
128
+ if self.show_command_palette and app.ENABLE_COMMAND_PALETTE
129
+ else None
130
+ )
131
+ return [
132
+ (binding, enabled, tooltip)
133
+ for key_str, (_node, binding, enabled, tooltip) in active.items()
134
+ if binding.show and key_str != palette_key
135
+ ]
136
+
137
+ def _get_palette_binding(self) -> tuple[Binding, bool, str] | None:
138
+ """Returns the command palette binding tuple or None."""
139
+ app = cast("App[object]", self.app)
140
+ if not (self.show_command_palette and app.ENABLE_COMMAND_PALETTE):
141
+ return None
142
+ try:
143
+ _node, binding, enabled, tooltip = self.screen.active_bindings[
144
+ app.COMMAND_PALETTE_BINDING
145
+ ]
146
+ except KeyError:
147
+ return None
148
+ return binding, enabled, tooltip
149
+
150
+ def _build_rows_auto(
151
+ self, bindings: list[tuple[Binding, bool, str]]
152
+ ) -> list[list[tuple[Binding, bool, str]]]:
153
+ """Distributes bindings across rows based on available width."""
154
+ available = self.size.width or 80
155
+ rows: list[list[tuple[Binding, bool, str]]] = [[]]
156
+ current_width = 0
157
+
158
+ app = cast("App[object]", self.app)
159
+ for binding, enabled, tooltip in bindings:
160
+ key_display = app.get_key_display(binding)
161
+ w = self._estimate_key_width(key_display, binding.description) + 1
162
+ if current_width + w > available and rows[-1]:
163
+ if self.max_rows > 0 and len(rows) >= self.max_rows:
164
+ rows[-1].append((binding, enabled, tooltip))
165
+ current_width += w
166
+ continue
167
+ rows.append([])
168
+ current_width = 0
169
+ rows[-1].append((binding, enabled, tooltip))
170
+ current_width += w
171
+
172
+ return rows
173
+
174
+ def _build_rows_manual(
175
+ self, bindings: list[tuple[Binding, bool, str]]
176
+ ) -> list[list[tuple[Binding, bool, str]]]:
177
+ """Distributes bindings across rows using the row_map."""
178
+ grouped: defaultdict[int, list[tuple[Binding, bool, str]]] = \
179
+ defaultdict(list)
180
+ fallback_row = max(self._row_map.values(), default=0)
181
+
182
+ for binding, enabled, tooltip in bindings:
183
+ grouped[self._row_map.get(binding.action, fallback_row)].append(
184
+ (binding, enabled, tooltip)
185
+ )
186
+ if not grouped:
187
+ return []
188
+ return [grouped[i] for i in sorted(grouped.keys())]
189
+
190
+ @override
191
+ def compose(self) -> ComposeResult:
192
+ if not self._bindings_ready:
193
+ return
194
+
195
+ app = cast("App[object]", self.app)
196
+ bindings = self._collect_bindings()
197
+ rows = (
198
+ self._build_rows_auto(bindings)
199
+ if self.auto_wrap
200
+ else self._build_rows_manual(bindings)
201
+ )
202
+ palette = self._get_palette_binding()
203
+
204
+ for row_idx, row_bindings in enumerate(rows):
205
+ with FooterRow():
206
+ for binding, enabled, tooltip in row_bindings:
207
+ yield FooterKey(
208
+ binding.key,
209
+ app.get_key_display(binding),
210
+ binding.description,
211
+ binding.action,
212
+ disabled=not enabled,
213
+ tooltip=tooltip or binding.description,
214
+ ).data_bind(compact=Footer.compact)
215
+ # Place the command palette in the first row
216
+ # if row_idx == 0 and palette:
217
+ # Place the command palette in the last row
218
+ if row_idx == len(rows) - 1 and palette:
219
+ b, en, tt = palette
220
+ yield FooterKey(
221
+ b.key,
222
+ app.get_key_display(b),
223
+ b.description,
224
+ b.action,
225
+ disabled=not en,
226
+ tooltip=tt or b.description,
227
+ classes="-command-palette",
228
+ )
229
+
230
+ @override
231
+ def on_mount(self) -> None:
232
+ super().on_mount()
233
+ # Trigger initial render (needed when the footer is mounted late)
234
+ self._bindings_ready = True
235
+ _ = self.call_after_refresh(self.recompose)
236
+
237
+ def on_resize(self) -> None:
238
+ """Re-wraps on terminal resize (auto-wrap mode only)."""
239
+ if self.auto_wrap and self._bindings_ready:
240
+ _ = self.call_after_refresh(self.recompose)
@@ -0,0 +1,107 @@
1
+ """
2
+ A modal screen that asks a yes/no question and returns the answer.
3
+
4
+ The screen displays a customizable question and two buttons labeled "Yes"
5
+ and "No". It uses Textual's event system to handle user interactions and
6
+ returns `True` or `False` accordingly. The button colors can be configured using
7
+ the `ButtonColor` enum, allowing visual emphasis (e.g., marking "Yes" as
8
+ destructive in red or "No" as a safe action in blue).
9
+
10
+ The screen can be dismissed using the escape key or by clicking a button. It is
11
+ ideal for confirmation dialogs, safety prompts or binary decision points within
12
+ an application UI.
13
+ """
14
+ from enum import Enum
15
+ from typing import ClassVar
16
+
17
+ from textual import on
18
+ from textual.app import ComposeResult
19
+ from textual.binding import BindingType
20
+ from textual.containers import Grid
21
+ from textual.screen import ModalScreen
22
+ from textual.widgets import Button, Label
23
+
24
+ __all__ = [
25
+ "ButtonColor",
26
+ "QuestionScreen",
27
+ ]
28
+
29
+
30
+ class ButtonColor(Enum):
31
+ """Enum for button colors."""
32
+
33
+ DEFAULT = "default"
34
+ PRIMARY = "primary"
35
+ ERROR = "error"
36
+ SUCCESS = "success"
37
+ WARNING = "warning"
38
+
39
+
40
+ class QuestionScreen(ModalScreen[bool]):
41
+ """A screen that asks a question and returns a boolean answer."""
42
+
43
+ yes_button_color: ButtonColor
44
+ no_button_color: ButtonColor
45
+ BINDINGS: ClassVar[list[BindingType]] = [
46
+ ("escape", "close_modal", "Close"),
47
+ ]
48
+ CSS = """
49
+ QuestionScreen {
50
+ align: center middle;
51
+ }
52
+
53
+ QuestionScreen #dialog {
54
+ grid-size: 2;
55
+ grid-gutter: 1 2;
56
+ grid-rows: 1fr 3;
57
+ padding: 0 1;
58
+ width: 60;
59
+ height: 11;
60
+ border: thick $background 80%;
61
+ background: $surface;
62
+ }
63
+
64
+ QuestionScreen #question {
65
+ column-span: 2;
66
+ height: 1fr;
67
+ width: 1fr;
68
+ content-align: center middle;
69
+ }
70
+
71
+ QuestionScreen Button {
72
+ width: 100%;
73
+ }
74
+ """
75
+
76
+
77
+ def __init__(self, question: str,
78
+ yes_button_color: ButtonColor = ButtonColor.ERROR,
79
+ no_button_color: ButtonColor = ButtonColor.PRIMARY) -> None:
80
+ """Initialize the QuestionScreen with a question."""
81
+ self.question = question
82
+ self.yes_button_color = yes_button_color
83
+ self.no_button_color = no_button_color
84
+ super().__init__()
85
+
86
+ def compose(self) -> ComposeResult:
87
+ """Compose the screen layout."""
88
+ yield Grid(
89
+ Label(self.question, id="question"),
90
+ Button("Yes", variant=self.yes_button_color.value, id="yes"),
91
+ Button("No", variant=self.no_button_color.value, id="no"),
92
+ id="dialog",
93
+ )
94
+
95
+ def action_close_modal(self) -> None:
96
+ """Closes the modal popup."""
97
+ self.dismiss(result=False)
98
+
99
+ @on(Button.Pressed, "#yes")
100
+ def handle_yes(self) -> None:
101
+ """Handle the "Yes" button press."""
102
+ self.dismiss(result=True)
103
+
104
+ @on(Button.Pressed, "#no")
105
+ def handle_no(self) -> None:
106
+ """Handle the "No" button press."""
107
+ self.dismiss(result=False)