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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- 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
|