composeui 0.1.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 (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.widgets import Markdown
4
+
5
+
6
+ class ComposeMarkdown(Markdown):
7
+ """A `Markdown` whose text is set at any time, mounted or not.
8
+
9
+ Its links open in the browser (Textual's `open_links`).
10
+ """
11
+
12
+ def set_text(self, text: str) -> None:
13
+ if self.is_mounted:
14
+ # not awaited: Textual mounts the new blocks in a task, one update after another
15
+ self.update(text)
16
+ else:
17
+ self._initial_markdown = text # what `Markdown` shows once mounted
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Label
7
+
8
+ if TYPE_CHECKING:
9
+ from textual.app import ComposeResult
10
+
11
+
12
+ class ComposeMetric(Vertical):
13
+ """A metric: its label, its value in bold, then its change as a badge.
14
+
15
+ A terminal has one font size; the value stands out by its weight alone.
16
+ """
17
+
18
+ DEFAULT_CSS = """
19
+ ComposeMetric {
20
+ height: auto;
21
+ width: 1fr;
22
+ }
23
+ ComposeMetric > .metric--label {
24
+ color: $text-muted;
25
+ }
26
+ ComposeMetric > .metric--value {
27
+ text-style: bold;
28
+ }
29
+ ComposeMetric > .metric--badge {
30
+ padding: 0 1;
31
+ background: $surface-lighten-1;
32
+ &.-rise {
33
+ color: $text-success;
34
+ background: $success-muted;
35
+ }
36
+ &.-fall {
37
+ color: $text-error;
38
+ background: $error-muted;
39
+ }
40
+ }
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+ self.label = Label(classes="metric--label", markup=False)
46
+ self.value = Label(classes="metric--value", markup=False)
47
+ self.badge = Label(classes="metric--badge", markup=False)
48
+ self.badge.display = False
49
+
50
+ def compose(self) -> ComposeResult:
51
+ yield self.label
52
+ yield self.value
53
+ yield self.badge
54
+
55
+ def set_delta(self, badge: str, sign: int) -> None:
56
+ """Show `badge`, colored by the `sign` of its change; an empty one hides it."""
57
+ self.badge.update(badge)
58
+ self.badge.display = badge != ""
59
+ self.badge.set_class(sign > 0, "-rise")
60
+ self.badge.set_class(sign < 0, "-fall")
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Generic, Optional, TypeVar
4
+
5
+ from textual.content import Content
6
+ from textual.widgets import RadioButton, RadioSet
7
+
8
+ from composeui.core.signal import Signal
9
+
10
+ TValue = TypeVar("TValue")
11
+
12
+
13
+ class ComposeRadioSet(RadioSet, Generic[TValue]):
14
+ """A `RadioSet` built from a `{label: value}` mapping, like Qt's `QRadioGroup`.
15
+
16
+ Textual dispatches `RadioSet.Changed` by calling `on_radio_set_changed` on the
17
+ widget itself first; a renderer isn't part of the DOM, so it can't bind to that
18
+ directly. Re-emitting it through a plain composeui `Signal` lets
19
+ `TextualRadioGroupRenderer` connect like it would to any other composeui signal.
20
+ """
21
+
22
+ # a form row's field while the row is invalid (see `ROW_INVALID_CLASS`)
23
+ DEFAULT_CSS = """
24
+ ComposeRadioSet.-row-invalid {
25
+ border: tall $error 60%;
26
+ &:focus {
27
+ border: tall $error;
28
+ }
29
+ }
30
+ """
31
+
32
+ def __init__(self, values: dict[str, TValue]) -> None:
33
+ self._values: dict[str, TValue] = dict(values)
34
+ super().__init__(*(RadioButton(Content(label)) for label in self._values))
35
+ self.value_changed: Signal[Optional[TValue]] = Signal()
36
+
37
+ def set_values(self, values: dict[str, TValue]) -> None:
38
+ """Rebuild the radio buttons for a new set of options."""
39
+ self.remove_children()
40
+ self._values = dict(values)
41
+ for label in self._values:
42
+ self.mount(RadioButton(Content(label)))
43
+
44
+ def set_value(self, value: Optional[TValue]) -> None:
45
+ for button, candidate in zip(self.query(RadioButton), self._values.values()):
46
+ if value is not None and value == candidate and not button.value:
47
+ button.value = True
48
+
49
+ def value(self) -> TValue:
50
+ assert self.pressed_index != -1 # asked once a button is pressed
51
+ return list(self._values.values())[self.pressed_index]
52
+
53
+ def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
54
+ self.value_changed.emit(self.value())
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from textual.binding import Binding, BindingType
6
+ from textual.containers import Horizontal, Vertical, VerticalScroll
7
+ from textual.widgets import Label, Static
8
+
9
+ from composeui.core.signal import Signal
10
+
11
+ if TYPE_CHECKING:
12
+ from textual import events
13
+ from textual.widget import Widget
14
+
15
+
16
+ class ComposeSidebarToggle(Static, can_focus=True):
17
+ """The arrow retracting or expanding a sidebar, by a click or Enter."""
18
+
19
+ BINDINGS: ClassVar[list[BindingType]] = [
20
+ Binding("enter,space", "press", "Retract or expand", show=False),
21
+ ]
22
+
23
+ def __init__(self, sidebar: ComposeSidebar) -> None:
24
+ super().__init__(classes="sidebar--toggle")
25
+ self._sidebar = sidebar
26
+
27
+ def on_click(self, _event: events.Click) -> None:
28
+ self.action_press()
29
+
30
+ def action_press(self) -> None:
31
+ self._sidebar.toggle_by_user()
32
+
33
+
34
+ class ComposeSidebar(VerticalScroll):
35
+ """A column on the screen's left: its title and an arrow, then its views, scrolling.
36
+
37
+ As wide as a third of the screen at most: the content beside it keeps the rest. The
38
+ arrow retracts it to a strip holding only the arrow, and expands it back; `toggled`
39
+ reports those, and `set_expanded` is quiet, as Qt's renderers block their widget's
40
+ signals while refreshing it.
41
+ """
42
+
43
+ DEFAULT_CSS = """
44
+ ComposeSidebar {
45
+ width: 36;
46
+ max-width: 33%;
47
+ height: 1fr;
48
+ padding: 0 1;
49
+ margin-right: 1;
50
+ border-right: vkey $foreground 20%;
51
+ & > .sidebar--header {
52
+ height: auto;
53
+ margin-bottom: 1;
54
+ }
55
+ & .sidebar--title {
56
+ width: 1fr;
57
+ text-style: bold;
58
+ }
59
+ & .sidebar--toggle {
60
+ width: 3;
61
+ content-align: center middle;
62
+ &:focus {
63
+ background: $accent;
64
+ color: $text-accent;
65
+ }
66
+ &:hover {
67
+ background: $boost;
68
+ }
69
+ }
70
+ & > .sidebar--content {
71
+ height: auto;
72
+ }
73
+ &.-retracted {
74
+ width: 5;
75
+ & > .sidebar--content {
76
+ display: none;
77
+ }
78
+ }
79
+ }
80
+ """
81
+
82
+ def __init__(self, *children: Widget) -> None:
83
+ self.title = Label(classes="sidebar--title", markup=False)
84
+ self.toggle = ComposeSidebarToggle(self)
85
+ super().__init__(
86
+ Horizontal(self.title, self.toggle, classes="sidebar--header"),
87
+ Vertical(*children, classes="sidebar--content"),
88
+ )
89
+ self.toggled: Signal[bool] = Signal()
90
+ self._title_text = ""
91
+ self.set_expanded(True)
92
+
93
+ @property
94
+ def is_expanded(self) -> bool:
95
+ return not self.has_class("-retracted")
96
+
97
+ def set_title(self, title: str) -> None:
98
+ self.title.update(title)
99
+ self._title_text = title
100
+ self.title.display = title != "" and self.is_expanded
101
+
102
+ def set_expanded(self, is_expanded: bool) -> None:
103
+ self.set_class(not is_expanded, "-retracted")
104
+ self.title.display = self._title_text != "" and is_expanded
105
+ # pointing the way the sidebar goes: left to retract it, right to expand it
106
+ self.toggle.update("◀" if is_expanded else "▶")
107
+
108
+ def toggle_by_user(self) -> None:
109
+ is_expanded = not self.is_expanded
110
+ self.set_expanded(is_expanded)
111
+ self.toggled.emit(is_expanded)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from rich.text import Text
6
+ from textual.binding import Binding, BindingType
7
+ from textual.widget import Widget
8
+
9
+ from composeui.core.signal import Signal
10
+
11
+ if TYPE_CHECKING:
12
+ from textual import events
13
+ from textual.app import RenderResult
14
+
15
+
16
+ class ComposeSlider(Widget, can_focus=True):
17
+ """A slider from `first` by `step`, its value on the right of the track.
18
+
19
+ Textual has none. Its positions count steps (0 to `last`), so the handle only stops
20
+ on a step. The arrow keys move it a step, Page Up/Down a tenth of the way, Home/End
21
+ to an end; a click or a drag moves it under the mouse. `moved` reports the user's
22
+ moves only, as Qt's renderers block their widget's signals while refreshing it.
23
+
24
+ Three lines tall, the track on the middle one, as an input's text: a form row is as
25
+ tall as an input's. The value has a slot as wide as the widest one, so the track
26
+ keeps its length as the value changes.
27
+ """
28
+
29
+ BINDINGS: ClassVar[list[BindingType]] = [
30
+ Binding("left,down", "move(-1)", "Decrease", show=False),
31
+ Binding("right,up", "move(1)", "Increase", show=False),
32
+ Binding("pagedown", "move_page(-1)", show=False),
33
+ Binding("pageup", "move_page(1)", show=False),
34
+ Binding("home", "move_to_end(0)", show=False),
35
+ Binding("end", "move_to_end(1)", show=False),
36
+ ]
37
+
38
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
39
+ "slider--value",
40
+ "slider--filled",
41
+ "slider--handle",
42
+ "slider--empty",
43
+ }
44
+
45
+ # a form row's field while the row is invalid (see `ROW_INVALID_CLASS`)
46
+ DEFAULT_CSS = """
47
+ ComposeSlider {
48
+ height: 3;
49
+ width: 1fr;
50
+ padding: 1 1;
51
+ & > .slider--filled {
52
+ color: $primary;
53
+ }
54
+ & > .slider--handle {
55
+ color: $primary;
56
+ }
57
+ & > .slider--empty {
58
+ color: $foreground 30%;
59
+ }
60
+ &:focus > .slider--handle {
61
+ color: $accent;
62
+ text-style: bold;
63
+ }
64
+ &.-row-invalid > .slider--filled, &.-row-invalid > .slider--handle {
65
+ color: $error;
66
+ }
67
+ }
68
+ """
69
+
70
+ def __init__(self) -> None:
71
+ super().__init__()
72
+ self.first = 0
73
+ self.step = 1
74
+ self.last = 0
75
+ self.position = 0
76
+ self._is_dragging = False
77
+ self.moved: Signal[int] = Signal()
78
+
79
+ @property
80
+ def shown_value(self) -> int:
81
+ return self.first + self.position * self.step
82
+
83
+ def set_steps(self, first: int, step: int, last: int, position: int) -> None:
84
+ """Show `position` out of `last` steps of `step` from `first`, without `moved`."""
85
+ self.first, self.step, self.last, self.position = first, step, last, position
86
+ self.refresh()
87
+
88
+ def render(self) -> RenderResult:
89
+ width = self._track_width()
90
+ if width <= 0:
91
+ return ""
92
+ handle = self._handle_column(width)
93
+ text = Text(no_wrap=True, overflow="crop")
94
+ text.append("━" * handle, style=self.get_component_rich_style("slider--filled"))
95
+ text.append("●", style=self.get_component_rich_style("slider--handle"))
96
+ empty = "─" * (width - handle - 1)
97
+ text.append(empty, style=self.get_component_rich_style("slider--empty"))
98
+ value = str(self.shown_value).rjust(self._slot_width())
99
+ text.append(f" {value}", style=self.get_component_rich_style("slider--value"))
100
+ return text
101
+
102
+ def _slot_width(self) -> int:
103
+ return max(len(str(self.first)), len(str(self.first + self.last * self.step)))
104
+
105
+ def _track_width(self) -> int:
106
+ """Get the track's columns: the content's, less the value's slot and a space."""
107
+ return self.content_size.width - self._slot_width() - 1
108
+
109
+ def _handle_column(self, width: int) -> int:
110
+ return 0 if self.last == 0 else round(self.position * (width - 1) / self.last)
111
+
112
+ def action_move(self, steps: int) -> None:
113
+ self._move_to(self.position + steps)
114
+
115
+ def action_move_page(self, direction: int) -> None:
116
+ self._move_to(self.position + direction * max(1, self.last // 10))
117
+
118
+ def action_move_to_end(self, end: int) -> None:
119
+ self._move_to(end * self.last)
120
+
121
+ def on_mouse_down(self, event: events.MouseDown) -> None:
122
+ self._is_dragging = True
123
+ self.capture_mouse()
124
+ self._move_to_column(event.get_content_offset_capture(self).x)
125
+
126
+ def on_mouse_move(self, event: events.MouseMove) -> None:
127
+ if self._is_dragging:
128
+ self._move_to_column(event.get_content_offset_capture(self).x)
129
+
130
+ def on_mouse_up(self, _event: events.MouseUp) -> None:
131
+ self._is_dragging = False
132
+ self.release_mouse()
133
+
134
+ def _move_to_column(self, column: int) -> None:
135
+ width = self._track_width()
136
+ if width > 1:
137
+ self._move_to(round(column * self.last / (width - 1)))
138
+
139
+ def _move_to(self, position: int) -> None:
140
+ position = min(max(0, position), self.last)
141
+ if position != self.position:
142
+ self.position = position
143
+ self.refresh()
144
+ self.moved.emit(self.shown_value)
@@ -0,0 +1,288 @@
1
+ """A large table's widget: a scrolled view that asks its view for the lines it draws."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol
6
+
7
+ from rich.segment import Segment
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import Vertical
10
+ from textual.geometry import Size
11
+ from textual.scroll_view import ScrollView
12
+ from textual.strip import Strip
13
+ from textual.widgets import Static
14
+ from textual_fspicker import FileSave, Filters
15
+ from textual_fspicker.path_filters import Filter
16
+
17
+ from composeui.core.signal import Signal, SignalNoArgs
18
+ from composeui.textualcore.widgets.composefiltertable import ComposeFilterBar
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+ from textual import events
24
+ from textual.app import ComposeResult
25
+
26
+
27
+ #: the key exporting the rows shown as CSV
28
+ EXPORT_KEY = "e"
29
+ #: the characters a cell shows as spaces, on one line
30
+ _SPACES = str.maketrans(dict.fromkeys("\t\n\r\v\f", " "))
31
+
32
+
33
+ def _is_csv(path: Path) -> bool:
34
+ return path.suffix.lower() == ".csv"
35
+
36
+
37
+ class SourceTableLike(Protocol):
38
+ """What the widget reads from a `SourceTableView`."""
39
+
40
+ @property
41
+ def headers(self) -> list[str]: ...
42
+ @property
43
+ def row_count(self) -> int: ...
44
+ def rows(self, start: int, stop: int) -> list[tuple[Any, list[str]]]: ...
45
+ def is_selected(self, row_id: Any) -> bool: ...
46
+
47
+
48
+ class ComposeSourceTable(ScrollView, can_focus=True):
49
+ """A large table: its headers on its first line, then a line per row, as scrolled.
50
+
51
+ Textual asks for the lines it draws (`render_line`), and the view reads their rows
52
+ from its source a window at a time: the widget keeps no row. Each line starts with
53
+ its row's position, from 1; the columns share the rest of the width, a cell too long
54
+ cut short. `reset` shows a new order of the rows, `repaint` the same rows formatted
55
+ or selected again.
56
+
57
+ `header_clicked` emits the column whose header the user clicked; the header shows
58
+ the order `set_sort` gives (▲ or ▼ after its header), the table never sorting its
59
+ rows itself.
60
+
61
+ A cursor marks a row, which the arrow keys, Page Up, Page Down, Home and End move,
62
+ the table scrolling to keep it shown. The table selects no row itself: `row_clicked`
63
+ emits the position of the row the user clicked, or the cursor's on Space, and whether
64
+ Shift extends the selection to it; `row_activated` emits it on a double click or
65
+ Enter. A selected row has the primary color. `copy_requested` emits on Ctrl+C. Its
66
+ key `e` asks where to export the rows as CSV, which `export_path_chosen` emits.
67
+ """
68
+
69
+ BINDINGS: ClassVar[list[BindingType]] = [
70
+ Binding("up", "move_cursor(-1)", "Up", show=False),
71
+ Binding("down", "move_cursor(1)", "Down", show=False),
72
+ Binding("pageup", "move_cursor('-page')", "Page up", show=False),
73
+ Binding("pagedown", "move_cursor('page')", "Page down", show=False),
74
+ Binding("home", "move_cursor('-all')", "First row", show=False),
75
+ Binding("end", "move_cursor('all')", "Last row", show=False),
76
+ Binding("space", "click_cursor(False)", "Select", show=False),
77
+ Binding("shift+space", "click_cursor(True)", "Select up to", show=False),
78
+ Binding("enter", "activate_cursor", "Activate", show=False),
79
+ Binding("ctrl+c", "copy_rows", "Copy", show=False),
80
+ ]
81
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
82
+ "data-table--header",
83
+ "data-table--even-row",
84
+ "data-table--cursor",
85
+ "data-table--selected",
86
+ }
87
+ DEFAULT_CSS = """
88
+ ComposeSourceTable {
89
+ height: 1fr;
90
+ min-height: 5;
91
+ overflow-x: hidden;
92
+ & > .data-table--header {
93
+ text-style: bold;
94
+ background: $panel;
95
+ }
96
+ & > .data-table--even-row {
97
+ background: $surface-lighten-1;
98
+ }
99
+ & > .data-table--selected {
100
+ background: $primary;
101
+ color: $text;
102
+ }
103
+ & > .data-table--cursor {
104
+ text-style: reverse;
105
+ }
106
+ }
107
+ """
108
+
109
+ def __init__(self, table: SourceTableLike) -> None:
110
+ super().__init__()
111
+ self._table = table
112
+ self._sort: tuple[int, bool] | None = None
113
+ #: the position of the row the cursor marks
114
+ self.cursor = 0
115
+ self.header_clicked: Signal[int] = Signal()
116
+ self.row_clicked: Signal[int, bool] = Signal()
117
+ self.row_activated: Signal[int] = Signal()
118
+ self.copy_requested: SignalNoArgs = SignalNoArgs()
119
+ self.export_path_chosen: Signal[Path] = Signal()
120
+ #: the name the export's dialog offers
121
+ self.export_filename = "table.csv"
122
+ self.set_export_text("Export CSV")
123
+
124
+ def set_export_text(self, text: str) -> None:
125
+ """Describe the key exporting the rows, e.g. in the app's footer."""
126
+ self._bindings.key_to_bindings.pop(EXPORT_KEY, None)
127
+ self._bindings.bind(EXPORT_KEY, "export", text)
128
+ self.refresh_bindings()
129
+
130
+ def action_export(self) -> None:
131
+ dialog = FileSave(
132
+ filters=Filters(Filter("CSV", _is_csv)), default_file=self.export_filename
133
+ )
134
+ self.app.push_screen(dialog, self._export_path_chosen)
135
+
136
+ def _export_path_chosen(self, path: Path | None) -> None:
137
+ if path is not None:
138
+ self.export_path_chosen.emit(
139
+ path if _is_csv(path) else path.with_name(f"{path.name}.csv")
140
+ )
141
+
142
+ def set_sort(self, sort: tuple[int, bool] | None) -> None:
143
+ """Show the order: by a column, descending or not; None for the source's own."""
144
+ self._sort = sort
145
+ self.refresh()
146
+
147
+ def action_move_cursor(self, step: int | str) -> None:
148
+ """Move the cursor by `step` rows, a page of them (``"page"``) or to an end."""
149
+ steps = {"page": self._shown_rows(), "-page": -self._shown_rows()}
150
+ steps.update({"all": self._table.row_count, "-all": -self._table.row_count})
151
+ count = int(steps.get(step, step) if isinstance(step, str) else step)
152
+ self.cursor = min(max(self.cursor + count, 0), max(self._table.row_count - 1, 0))
153
+ self._show_cursor()
154
+
155
+ def action_click_cursor(self, is_extended: bool) -> None:
156
+ if self.cursor < self._table.row_count:
157
+ self.row_clicked.emit(self.cursor, is_extended)
158
+
159
+ def action_copy_rows(self) -> None:
160
+ self.copy_requested.emit()
161
+
162
+ def action_activate_cursor(self) -> None:
163
+ if self.cursor < self._table.row_count:
164
+ self.row_activated.emit(self.cursor)
165
+
166
+ def on_click(self, event: events.Click) -> None:
167
+ if event.y > 0:
168
+ row = round(self.scroll_offset.y) + event.y - 1
169
+ if row < self._table.row_count:
170
+ self.cursor = row
171
+ self.refresh()
172
+ if event.chain == 2: # noqa: PLR2004 (a double click)
173
+ self.row_activated.emit(row)
174
+ else:
175
+ self.row_clicked.emit(row, event.shift)
176
+ return
177
+ number_width = self._number_width()
178
+ count = len(self._table.headers)
179
+ if count == 0 or event.x < number_width:
180
+ return
181
+ share = max((self._width() - number_width) // count, 1)
182
+ column = (event.x - number_width) // share
183
+ if column < count:
184
+ self.header_clicked.emit(column)
185
+
186
+ def reset(self) -> None:
187
+ # the header's line, then the rows'
188
+ self.virtual_size = Size(self._width(), self._table.row_count + 1)
189
+ self.cursor = min(self.cursor, max(self._table.row_count - 1, 0))
190
+ self.refresh()
191
+
192
+ def repaint(self) -> None:
193
+ self.refresh()
194
+
195
+ def on_resize(self, _event: events.Resize) -> None:
196
+ self.virtual_size = Size(self._width(), self._table.row_count + 1)
197
+
198
+ def render_line(self, y: int) -> Strip:
199
+ width = self._width()
200
+ number_width = self._number_width()
201
+ if y == 0: # the headers stay on top, whatever the scroll
202
+ headers = list(self._table.headers)
203
+ if self._sort is not None and self._sort[0] < len(headers):
204
+ column, is_descending = self._sort
205
+ headers[column] += " ▼" if is_descending else " ▲"
206
+ text = self._line(" " * number_width, headers, width - number_width)
207
+ return Strip([Segment(text, self.get_component_rich_style("data-table--header"))])
208
+ row = round(self.scroll_offset.y) + y - 1
209
+ rows = self._table.rows(row, row + 1)
210
+ if len(rows) == 0:
211
+ return Strip.blank(width, self.rich_style)
212
+ row_id, cells = rows[0]
213
+ position = f"{row + 1:,}".rjust(number_width - 1) + " "
214
+ text = self._line(position, cells, width - number_width)
215
+ style = self.rich_style
216
+ if self._table.is_selected(row_id):
217
+ style += self.get_component_rich_style("data-table--selected")
218
+ elif row % 2 == 1:
219
+ style += self.get_component_rich_style("data-table--even-row")
220
+ if row == self.cursor and self.has_focus:
221
+ style += self.get_component_rich_style("data-table--cursor")
222
+ return Strip([Segment(text, style)])
223
+
224
+ def _shown_rows(self) -> int:
225
+ """Get the number of rows shown at once, under the headers' line."""
226
+ return max(self.scrollable_content_region.height - 1, 1)
227
+
228
+ def _show_cursor(self) -> None:
229
+ """Scroll as little as needed for the cursor's row to show."""
230
+ top = round(self.scroll_offset.y)
231
+ if self.cursor < top:
232
+ self.scroll_to(y=self.cursor, animate=False)
233
+ elif self.cursor >= top + self._shown_rows():
234
+ self.scroll_to(y=self.cursor - self._shown_rows() + 1, animate=False)
235
+ self.refresh()
236
+
237
+ def _width(self) -> int:
238
+ """Get the width of the lines: the widget's, but its vertical scroll bar's."""
239
+ return self.scrollable_content_region.width
240
+
241
+ def _number_width(self) -> int:
242
+ """Get the width of the rows' positions, before their cells."""
243
+ return len(f"{self._table.row_count:,}") + 1
244
+
245
+ @staticmethod
246
+ def _line(start: str, cells: list[str], width: int) -> str:
247
+ """Get a line of `cells` after `start`, each in an equal share of `width`."""
248
+ if len(cells) == 0:
249
+ return start.ljust(len(start) + max(width, 0))
250
+ share = max(width // len(cells), 1)
251
+ # a line break or a tab would break the line: a space, as Ctrl+C copies it
252
+ flat = [cell.translate(_SPACES) for cell in cells]
253
+ parts = [
254
+ (cell if len(cell) < share else cell[: max(share - 2, 0)] + "…").ljust(share)
255
+ for cell in flat
256
+ ]
257
+ return (start + "".join(parts)).ljust(len(start) + max(width, 0))
258
+
259
+
260
+ class ComposeDataFilterTable(Vertical):
261
+ """A large table under its filter bar, which shows only while a column filters.
262
+
263
+ When the search or the filters hide every row, `empty` says so under the headers.
264
+ """
265
+
266
+ DEFAULT_CSS = """
267
+ ComposeDataFilterTable {
268
+ height: 1fr;
269
+ & > Static.empty {
270
+ color: $text-muted;
271
+ text-align: center;
272
+ width: 1fr;
273
+ }
274
+ }
275
+ """
276
+
277
+ def __init__(self, table: SourceTableLike) -> None:
278
+ super().__init__()
279
+ self.bar = ComposeFilterBar()
280
+ self.bar.display = False
281
+ self.table = ComposeSourceTable(table)
282
+ self.empty = Static("", classes="empty", markup=False)
283
+ self.empty.display = False
284
+
285
+ def compose(self) -> ComposeResult:
286
+ yield self.bar
287
+ yield self.table
288
+ yield self.empty