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.
- termcore/__init__.py +6 -0
- termcore/cli/__init__.py +3 -0
- termcore/cli/output.py +70 -0
- termcore/io/__init__.py +7 -0
- termcore/io/app_state_storage.py +185 -0
- termcore/io/database.py +655 -0
- termcore/io/errors.py +71 -0
- termcore/io/file.py +263 -0
- termcore/io/textfile.py +92 -0
- termcore/py.typed +0 -0
- termcore/tui/__init__.py +9 -0
- termcore/tui/custom_bindings.py +551 -0
- termcore/tui/custom_widgets/__init__.py +1 -0
- termcore/tui/custom_widgets/custom_checkbox.py +41 -0
- termcore/tui/custom_widgets/custom_data_table.py +247 -0
- termcore/tui/custom_widgets/custom_selection.py +80 -0
- termcore/tui/custom_widgets/multiline_footer.py +240 -0
- termcore/tui/question_screen.py +107 -0
- termcore/tui/theme_loader.py +495 -0
- termcore/tui/themes/__init__.py +2 -0
- termcore/tui/themes/classic-black-saturated/style.css +57 -0
- termcore/tui/themes/classic-black-saturated/theme.py +22 -0
- termcore/tui/themes/classic-black-v1/style.css +57 -0
- termcore/tui/themes/classic-black-v1/theme.py +22 -0
- termcore/tui/themes/classic-black-v2/style.css +57 -0
- termcore/tui/themes/classic-black-v2/theme.py +24 -0
- termcore/tui/themes/classic-blue/theme.py +21 -0
- termcore/tui/themes/compact-gray/style.tcss +175 -0
- termcore/tui/themes/compact-gray/theme.py +22 -0
- termcore/tui/themes/mnml-black/style.css +57 -0
- termcore/tui/themes/mnml-black/theme.py +21 -0
- termcore/tui/themes/mnml-deepblack/style.css +57 -0
- termcore/tui/themes/mnml-deepblack/theme.py +21 -0
- termcore/tui/themes/pure-amber/theme.py +18 -0
- termcore/tui/themes/pure-black/theme.py +18 -0
- termcore/tui/themes/pure-blue/theme.py +18 -0
- termcore/tui/themes/pure-green/theme.py +18 -0
- termcore/tui/themes/pure-sweet16/theme.py +20 -0
- termcore/tui/themes/xplore-black/theme.py +18 -0
- termcore/tui/themes/xplore-blue/theme.py +18 -0
- termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
- termcore/tui/themes/xplore-teal/theme.py +18 -0
- termcore/util/__init__.py +10 -0
- termcore/util/datetime.py +173 -0
- termcore/util/debug.py +196 -0
- termcore/util/index.py +112 -0
- termcore/util/logger.py +36 -0
- termcore/util/singleton.py +46 -0
- termcore/util/string.py +263 -0
- termcore/util/validation.py +31 -0
- termcore/util/version.py +56 -0
- termcore-0.2.0.dist-info/METADATA +504 -0
- termcore-0.2.0.dist-info/RECORD +55 -0
- termcore-0.2.0.dist-info/WHEEL +4 -0
- 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)
|