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,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar, Optional
|
|
4
|
+
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.content import Content
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Label, Static
|
|
10
|
+
|
|
11
|
+
from composeui.core.view import DialogKind
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.binding import BindingType
|
|
16
|
+
|
|
17
|
+
from composeui.core.view import DialogView
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ComposeDialog(ModalScreen[Optional[bool]]):
|
|
21
|
+
"""A dialog's modal screen, dismissed with True (accepted), False (rejected) or None.
|
|
22
|
+
|
|
23
|
+
None is a dialog closed with Escape, without a button. Built from the dialog's view
|
|
24
|
+
as it opens.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
ComposeDialog {
|
|
29
|
+
align: center middle;
|
|
30
|
+
}
|
|
31
|
+
ComposeDialog > Vertical {
|
|
32
|
+
width: auto;
|
|
33
|
+
min-width: 40;
|
|
34
|
+
max-width: 80%;
|
|
35
|
+
height: auto;
|
|
36
|
+
padding: 1 2;
|
|
37
|
+
border: thick $primary 80%;
|
|
38
|
+
background: $surface;
|
|
39
|
+
}
|
|
40
|
+
ComposeDialog .title {
|
|
41
|
+
text-style: bold;
|
|
42
|
+
margin-bottom: 1;
|
|
43
|
+
}
|
|
44
|
+
ComposeDialog Horizontal {
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: auto;
|
|
47
|
+
align-horizontal: right;
|
|
48
|
+
margin-top: 1;
|
|
49
|
+
}
|
|
50
|
+
ComposeDialog Button {
|
|
51
|
+
margin-left: 1;
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_dialog", "Close")]
|
|
56
|
+
|
|
57
|
+
def __init__(self, view: DialogView) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.title_label = Label(view.title, classes="title", markup=False)
|
|
60
|
+
self.title_label.display = view.title != ""
|
|
61
|
+
self.text_label = Static(view.text, markup=False)
|
|
62
|
+
self.accept_button = Button(Content(view.accept_text), variant="primary", id="accept")
|
|
63
|
+
self.reject_button = Button(Content(view.reject_text), id="reject")
|
|
64
|
+
self.reject_button.display = view.kind is DialogKind.CONFIRM
|
|
65
|
+
self.is_enabled = view.is_enabled
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
with Vertical():
|
|
69
|
+
yield self.title_label
|
|
70
|
+
yield self.text_label
|
|
71
|
+
with Horizontal():
|
|
72
|
+
yield self.reject_button
|
|
73
|
+
yield self.accept_button
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
self.set_enabled(is_enabled=self.is_enabled)
|
|
77
|
+
self.accept_button.focus()
|
|
78
|
+
|
|
79
|
+
def set_enabled(self, *, is_enabled: bool) -> None:
|
|
80
|
+
self.is_enabled = is_enabled
|
|
81
|
+
self.accept_button.disabled = not is_enabled
|
|
82
|
+
self.reject_button.disabled = not is_enabled
|
|
83
|
+
|
|
84
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
85
|
+
event.stop()
|
|
86
|
+
self.dismiss(event.button is self.accept_button)
|
|
87
|
+
|
|
88
|
+
def action_dismiss_dialog(self) -> None:
|
|
89
|
+
if self.is_enabled:
|
|
90
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widgets import Button
|
|
8
|
+
from textual_fspicker import FileOpen
|
|
9
|
+
from textual_fspicker.path_filters import Filter, Filters
|
|
10
|
+
|
|
11
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
12
|
+
from composeui.textualcore.widgets.composeinput import ComposeInput
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _has_any_extension(path: Path, extensions: tuple[str, ...]) -> bool:
|
|
16
|
+
return any(path.name.endswith(extension) for extension in extensions)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ComposeFilePathInput(Horizontal):
|
|
20
|
+
"""A field typing a file's path, then a "…" button picking one, and a reload button.
|
|
21
|
+
|
|
22
|
+
The picker is textual-fspicker's, a screen of its own, opening in the directory of
|
|
23
|
+
the field's path, if any; `path_picked` reports the file picked, once the field shows
|
|
24
|
+
it. The reload button only reports its presses, and shows with `has_reload`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
ComposeFilePathInput {
|
|
29
|
+
height: auto;
|
|
30
|
+
& > ComposeInput {
|
|
31
|
+
width: 1fr;
|
|
32
|
+
}
|
|
33
|
+
& > Button {
|
|
34
|
+
min-width: 5;
|
|
35
|
+
width: 5;
|
|
36
|
+
}
|
|
37
|
+
&.-row-invalid > ComposeInput {
|
|
38
|
+
border: tall $error 60%;
|
|
39
|
+
&:focus {
|
|
40
|
+
border: tall $error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.input = ComposeInput()
|
|
48
|
+
self.browse_button = Button("…")
|
|
49
|
+
self.reload_button = Button("↻")
|
|
50
|
+
self.reload_button.display = False
|
|
51
|
+
super().__init__(self.input, self.browse_button, self.reload_button)
|
|
52
|
+
#: the file types the picker offers, by name, as the view's `extensions`
|
|
53
|
+
self.extensions: dict[str, tuple[str, ...]] = {}
|
|
54
|
+
#: the picker's title, the view's `browse_text`
|
|
55
|
+
self.picker_title = ""
|
|
56
|
+
self.path_picked: Signal[Path] = Signal()
|
|
57
|
+
self.reload_pressed = SignalNoArgs()
|
|
58
|
+
|
|
59
|
+
def open_picker(self) -> None:
|
|
60
|
+
"""Show the file picker in the directory of the field's path, else the current one."""
|
|
61
|
+
text = self.input.value.strip()
|
|
62
|
+
location = Path(text).parent if text != "" else Path()
|
|
63
|
+
if not location.is_dir(): # e.g. a path typed halfway
|
|
64
|
+
location = Path()
|
|
65
|
+
filters = Filters(
|
|
66
|
+
*(
|
|
67
|
+
Filter(name, functools.partial(_has_any_extension, extensions=extensions))
|
|
68
|
+
for name, extensions in self.extensions.items()
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
picker = FileOpen(location, self.picker_title or "Open", filters=filters)
|
|
72
|
+
self.app.push_screen(picker, self._pick)
|
|
73
|
+
|
|
74
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
75
|
+
event.stop()
|
|
76
|
+
if event.button is self.browse_button:
|
|
77
|
+
self.open_picker()
|
|
78
|
+
else:
|
|
79
|
+
self.reload_pressed.emit()
|
|
80
|
+
|
|
81
|
+
def _pick(self, path: Path | None) -> None:
|
|
82
|
+
if path is not None:
|
|
83
|
+
self.input.value = str(path)
|
|
84
|
+
self.path_picked.emit(path)
|
|
85
|
+
self.input.focus()
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Callable
|
|
4
|
+
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Horizontal, HorizontalScroll, Vertical
|
|
7
|
+
from textual.content import Content
|
|
8
|
+
from textual.widgets import Button, Input, Select, Static
|
|
9
|
+
|
|
10
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
11
|
+
from composeui.core.view import SEARCH_DELAY_MS, as_pattern, split_pattern
|
|
12
|
+
from composeui.textualcore.widgets.composetable import ComposeTable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#: a chip's remove button: a multiplication sign, as a close button often is
|
|
20
|
+
REMOVE = "\u00d7"
|
|
21
|
+
#: after a chip's text: a pencil, as its click edits the filter
|
|
22
|
+
EDIT = "\u270e"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ValueInput(Input):
|
|
26
|
+
"""The filter editor's value field: Escape closes the editor."""
|
|
27
|
+
|
|
28
|
+
BINDINGS = [Binding("escape", "close_editor", "Close", show=False)] # noqa: RUF012
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__(compact=True)
|
|
32
|
+
self.escaped = SignalNoArgs()
|
|
33
|
+
|
|
34
|
+
def action_close_editor(self) -> None:
|
|
35
|
+
self.escaped.emit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ComposeFilterChip(Horizontal):
|
|
39
|
+
"""A filter, as its column's header then its text, opening it, and a button removing it.
|
|
40
|
+
|
|
41
|
+
The pencil after the text and the tooltip say a click edits it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
DEFAULT_CSS = """
|
|
45
|
+
ComposeFilterChip {
|
|
46
|
+
width: auto;
|
|
47
|
+
height: 1;
|
|
48
|
+
margin-right: 1;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, column: int, text: str, edit_text: str, remove_text: str) -> None:
|
|
53
|
+
super().__init__()
|
|
54
|
+
self.column = column
|
|
55
|
+
self.open_button = Button(
|
|
56
|
+
Content(f"{text} {EDIT}"),
|
|
57
|
+
compact=True,
|
|
58
|
+
classes="chip-text",
|
|
59
|
+
tooltip=Content(edit_text),
|
|
60
|
+
)
|
|
61
|
+
self.remove_button = Button(
|
|
62
|
+
REMOVE, compact=True, classes="chip-remove", tooltip=Content(remove_text)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def compose(self) -> ComposeResult:
|
|
66
|
+
yield self.open_button
|
|
67
|
+
yield self.remove_button
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ComposeFilterBar(Horizontal):
|
|
71
|
+
"""A table's filter bar, on one line: a search, the filters as chips, the rows shown.
|
|
72
|
+
|
|
73
|
+
The chips scroll sideways when they don't fit.
|
|
74
|
+
|
|
75
|
+
"+ Filter" opens an editor in the bar, of a column, a value and an Add button, and in
|
|
76
|
+
a text column a toggle (`pattern`, primary while on) making the value a regular
|
|
77
|
+
expression: Enter or Add adds the filter, unless `check` says why its value can't
|
|
78
|
+
filter, marking the value field `-invalid` with the reason as its tooltip; Escape
|
|
79
|
+
closes it. A chip's
|
|
80
|
+
text opens the editor on its filter, its remove button removes it. The signals emit only for the
|
|
81
|
+
user: `search_edited` the text typed, `filter_added` a column and its text,
|
|
82
|
+
`filter_removed` a column, and `filters_cleared`. `search_edited` waits
|
|
83
|
+
`SEARCH_DELAY_MS` after the last key, but emits at once on Enter or once the search is
|
|
84
|
+
cleared.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
DEFAULT_CSS = """
|
|
88
|
+
ComposeFilterBar {
|
|
89
|
+
height: 1;
|
|
90
|
+
margin-bottom: 1;
|
|
91
|
+
& > Input#search {
|
|
92
|
+
width: 20;
|
|
93
|
+
margin-right: 1;
|
|
94
|
+
}
|
|
95
|
+
& > Select {
|
|
96
|
+
width: 28;
|
|
97
|
+
}
|
|
98
|
+
& > Input#value {
|
|
99
|
+
width: 20;
|
|
100
|
+
}
|
|
101
|
+
& > Button {
|
|
102
|
+
margin-right: 1;
|
|
103
|
+
}
|
|
104
|
+
/* the chips take the room left, scrolling: Textual lays nothing out wrapping */
|
|
105
|
+
& > HorizontalScroll#chips {
|
|
106
|
+
width: 1fr;
|
|
107
|
+
height: 1;
|
|
108
|
+
scrollbar-size-horizontal: 0;
|
|
109
|
+
}
|
|
110
|
+
& > Static#count {
|
|
111
|
+
width: auto;
|
|
112
|
+
color: $text-muted;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
super().__init__()
|
|
119
|
+
#: why a column's text can't filter it, or None if it can
|
|
120
|
+
self.check: Callable[[int, str], str | None] = lambda _column, _text: None
|
|
121
|
+
#: a hint at what filters a column, as a regular expression or not
|
|
122
|
+
self.placeholder: Callable[[int, bool], str] = lambda _column, _is_pattern: ""
|
|
123
|
+
#: whether a regular expression filters a column
|
|
124
|
+
self.takes_pattern: Callable[[int], bool] = lambda _column: False
|
|
125
|
+
self.search_edited: Signal[str] = Signal()
|
|
126
|
+
self.filter_added: Signal[int, str] = Signal()
|
|
127
|
+
self.filter_removed: Signal[int] = Signal()
|
|
128
|
+
self.filters_cleared: SignalNoArgs = SignalNoArgs()
|
|
129
|
+
self._search_timer: Timer | None = None
|
|
130
|
+
self._texts: dict[str, str] = {}
|
|
131
|
+
self._filters: list[tuple[int, str, str, str]] = []
|
|
132
|
+
#: the column whose chip takes the focus once shown, its filter just added
|
|
133
|
+
self._focused_chip: int | None = None
|
|
134
|
+
self.search = Input(id="search", compact=True)
|
|
135
|
+
self.add_filter = Button("", id="add-filter", compact=True)
|
|
136
|
+
self.column: Select[int] = Select([], id="column", compact=True, allow_blank=True)
|
|
137
|
+
self.pattern = Button(".*", id="pattern", compact=True)
|
|
138
|
+
self.value = _ValueInput()
|
|
139
|
+
self.value.id = "value"
|
|
140
|
+
self.value.escaped.connect(self.close_editor)
|
|
141
|
+
self.add = Button("", id="add", compact=True)
|
|
142
|
+
self.chips = HorizontalScroll(id="chips")
|
|
143
|
+
self.clear = Button("", id="clear", compact=True)
|
|
144
|
+
self.count = Static("", id="count", markup=False)
|
|
145
|
+
self._columns: list[tuple[int, str]] = []
|
|
146
|
+
self.close_editor()
|
|
147
|
+
|
|
148
|
+
def compose(self) -> ComposeResult:
|
|
149
|
+
yield self.search
|
|
150
|
+
yield self.add_filter
|
|
151
|
+
yield self.column
|
|
152
|
+
yield self.pattern
|
|
153
|
+
yield self.value
|
|
154
|
+
yield self.add
|
|
155
|
+
yield self.chips
|
|
156
|
+
yield self.clear
|
|
157
|
+
yield self.count
|
|
158
|
+
|
|
159
|
+
def on_mount(self) -> None:
|
|
160
|
+
self._offer_columns()
|
|
161
|
+
self._show_chips()
|
|
162
|
+
|
|
163
|
+
def set_texts(self, texts: dict[str, str]) -> None:
|
|
164
|
+
self._texts = texts
|
|
165
|
+
self.search.placeholder = texts.get("search", "")
|
|
166
|
+
self.add_filter.label = Content(texts.get("add_filter", ""))
|
|
167
|
+
self.add.label = Content(texts.get("add", ""))
|
|
168
|
+
self.clear.label = Content(texts.get("clear", ""))
|
|
169
|
+
self.column.tooltip = Content(texts.get("column", ""))
|
|
170
|
+
self.pattern.tooltip = Content(texts.get("pattern", ""))
|
|
171
|
+
self.value.tooltip = None
|
|
172
|
+
self._show_chips()
|
|
173
|
+
|
|
174
|
+
def set_columns(self, columns: list[tuple[int, str]]) -> None:
|
|
175
|
+
"""Offer `columns`, each its index and its header, in the editor."""
|
|
176
|
+
self._columns = columns
|
|
177
|
+
if self.is_mounted:
|
|
178
|
+
self._offer_columns()
|
|
179
|
+
|
|
180
|
+
def _offer_columns(self) -> None:
|
|
181
|
+
"""Offer the columns, the one picked still picked, else the first."""
|
|
182
|
+
selected = self.column.value
|
|
183
|
+
self.column.set_options((Content(header), column) for column, header in self._columns)
|
|
184
|
+
columns = [column for column, _ in self._columns]
|
|
185
|
+
if selected in columns:
|
|
186
|
+
self.column.value = selected
|
|
187
|
+
elif len(columns) > 0:
|
|
188
|
+
self.column.value = columns[0]
|
|
189
|
+
|
|
190
|
+
def set_search(self, text: str) -> None:
|
|
191
|
+
if self.search.value != text:
|
|
192
|
+
self._stop_search_timer() # the app's search, not the user's typed before
|
|
193
|
+
with self.search.prevent(Input.Changed):
|
|
194
|
+
self.search.value = text
|
|
195
|
+
|
|
196
|
+
def set_filters(self, filters: list[tuple[int, str, str, str]]) -> None:
|
|
197
|
+
"""Show each filter as a chip: its column, the chip's text, the text typed, the header."""
|
|
198
|
+
self._filters = filters
|
|
199
|
+
self._show_chips()
|
|
200
|
+
|
|
201
|
+
def set_count(self, text: str) -> None:
|
|
202
|
+
self.count.update(text)
|
|
203
|
+
self.count.display = text != ""
|
|
204
|
+
|
|
205
|
+
def open_editor(self, column: int | None = None, text: str = "") -> None:
|
|
206
|
+
if column is not None and self.is_mounted:
|
|
207
|
+
self.column.value = column
|
|
208
|
+
text, is_pattern = split_pattern(text)
|
|
209
|
+
self.set_pattern(is_pattern=is_pattern)
|
|
210
|
+
with self.value.prevent(Input.Changed):
|
|
211
|
+
self.value.value = text
|
|
212
|
+
self._mark_invalid(None)
|
|
213
|
+
for widget in (self.column, self.value, self.add):
|
|
214
|
+
widget.display = True
|
|
215
|
+
self.add_filter.display = False
|
|
216
|
+
self._show_placeholder()
|
|
217
|
+
self.value.focus()
|
|
218
|
+
|
|
219
|
+
def close_editor(self) -> None:
|
|
220
|
+
had_focus = self.value.has_focus
|
|
221
|
+
for widget in (self.column, self.pattern, self.value, self.add):
|
|
222
|
+
widget.display = False
|
|
223
|
+
self.add_filter.display = True
|
|
224
|
+
if had_focus:
|
|
225
|
+
self.add_filter.focus()
|
|
226
|
+
|
|
227
|
+
def is_pattern(self) -> bool:
|
|
228
|
+
"""Tell whether the value is a regular expression: the toggle is on."""
|
|
229
|
+
return self.pattern.variant == "primary"
|
|
230
|
+
|
|
231
|
+
def set_pattern(self, *, is_pattern: bool) -> None:
|
|
232
|
+
self.pattern.variant = "primary" if is_pattern else "default"
|
|
233
|
+
self._show_placeholder()
|
|
234
|
+
|
|
235
|
+
def is_editing(self) -> bool:
|
|
236
|
+
return bool(self.value.display)
|
|
237
|
+
|
|
238
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
239
|
+
event.stop()
|
|
240
|
+
if event.input is self.search:
|
|
241
|
+
self._stop_search_timer()
|
|
242
|
+
if event.value == "":
|
|
243
|
+
self._emit_search()
|
|
244
|
+
else:
|
|
245
|
+
self._search_timer = self.set_timer(SEARCH_DELAY_MS / 1000, self._emit_search)
|
|
246
|
+
|
|
247
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
248
|
+
event.stop()
|
|
249
|
+
if event.input is self.value:
|
|
250
|
+
self._add()
|
|
251
|
+
elif event.input is self.search and self._search_timer is not None:
|
|
252
|
+
self._emit_search()
|
|
253
|
+
|
|
254
|
+
def _stop_search_timer(self) -> None:
|
|
255
|
+
if self._search_timer is not None:
|
|
256
|
+
self._search_timer.stop()
|
|
257
|
+
self._search_timer = None
|
|
258
|
+
|
|
259
|
+
def _emit_search(self) -> None:
|
|
260
|
+
self._stop_search_timer()
|
|
261
|
+
self.search_edited.emit(self.search.value)
|
|
262
|
+
|
|
263
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
264
|
+
event.stop()
|
|
265
|
+
self._show_placeholder()
|
|
266
|
+
|
|
267
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
268
|
+
event.stop()
|
|
269
|
+
button = event.button
|
|
270
|
+
if button is self.add_filter:
|
|
271
|
+
self.open_editor()
|
|
272
|
+
elif button is self.add:
|
|
273
|
+
self._add()
|
|
274
|
+
elif button is self.pattern:
|
|
275
|
+
self.set_pattern(is_pattern=not self.is_pattern())
|
|
276
|
+
elif button is self.clear:
|
|
277
|
+
self.filters_cleared.emit()
|
|
278
|
+
else: # a chip's
|
|
279
|
+
chip = button.parent
|
|
280
|
+
assert isinstance(chip, ComposeFilterChip)
|
|
281
|
+
if button is chip.remove_button:
|
|
282
|
+
self.filter_removed.emit(chip.column)
|
|
283
|
+
else:
|
|
284
|
+
typed = next(
|
|
285
|
+
typed for column, _, typed, _ in self._filters if column == chip.column
|
|
286
|
+
)
|
|
287
|
+
self.open_editor(chip.column, typed)
|
|
288
|
+
|
|
289
|
+
def _show_chips(self) -> None:
|
|
290
|
+
self.clear.display = len(self._filters) >= 2 # noqa: PLR2004
|
|
291
|
+
if not self.is_mounted:
|
|
292
|
+
return
|
|
293
|
+
self.chips.remove_children()
|
|
294
|
+
remove, edit = self._texts.get("remove", "{header}"), self._texts.get("edit", "{header}")
|
|
295
|
+
chips = [
|
|
296
|
+
ComposeFilterChip(
|
|
297
|
+
column, text, edit.format(header=header), remove.format(header=header)
|
|
298
|
+
)
|
|
299
|
+
for column, text, _typed, header in self._filters
|
|
300
|
+
]
|
|
301
|
+
self.chips.mount_all(chips)
|
|
302
|
+
for chip in chips:
|
|
303
|
+
if chip.column == self._focused_chip:
|
|
304
|
+
self.call_after_refresh(chip.open_button.focus)
|
|
305
|
+
self._focused_chip = None
|
|
306
|
+
|
|
307
|
+
def _add(self) -> None:
|
|
308
|
+
column = self.column.value
|
|
309
|
+
if not isinstance(column, int):
|
|
310
|
+
return
|
|
311
|
+
text = self.value.value
|
|
312
|
+
if self.is_pattern() and self.takes_pattern(column):
|
|
313
|
+
text = as_pattern(text)
|
|
314
|
+
reason = self.check(column, text)
|
|
315
|
+
if reason is not None:
|
|
316
|
+
self._mark_invalid(reason)
|
|
317
|
+
return
|
|
318
|
+
self.close_editor()
|
|
319
|
+
# its chip takes the focus: Enter opens it again
|
|
320
|
+
self._focused_chip = column
|
|
321
|
+
self.filter_added.emit(column, text)
|
|
322
|
+
self._focused_chip = None
|
|
323
|
+
|
|
324
|
+
def _mark_invalid(self, reason: str | None) -> None:
|
|
325
|
+
self.value.set_class(reason is not None, "-invalid")
|
|
326
|
+
self.value.tooltip = None if reason is None else Content(reason)
|
|
327
|
+
|
|
328
|
+
def _show_placeholder(self) -> None:
|
|
329
|
+
column = self.column.value
|
|
330
|
+
if not isinstance(column, int):
|
|
331
|
+
self.value.placeholder = ""
|
|
332
|
+
return
|
|
333
|
+
# a toggle only where a regular expression filters, while editing
|
|
334
|
+
takes_pattern = self.takes_pattern(column)
|
|
335
|
+
self.pattern.display = takes_pattern and bool(self.value.display)
|
|
336
|
+
self.value.placeholder = self.placeholder(column, takes_pattern and self.is_pattern())
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ComposeFilterTable(Vertical):
|
|
340
|
+
"""A table of text under its filter bar, which shows only while a column filters.
|
|
341
|
+
|
|
342
|
+
When the filters hide every row, `empty` says so under the headers.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
DEFAULT_CSS = """
|
|
346
|
+
ComposeFilterTable {
|
|
347
|
+
height: auto;
|
|
348
|
+
& > Static.empty {
|
|
349
|
+
color: $text-muted;
|
|
350
|
+
text-align: center;
|
|
351
|
+
width: 1fr;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self) -> None:
|
|
357
|
+
super().__init__()
|
|
358
|
+
self.bar = ComposeFilterBar()
|
|
359
|
+
self.bar.display = False
|
|
360
|
+
self.table = ComposeTable()
|
|
361
|
+
self.empty = Static("", classes="empty", markup=False)
|
|
362
|
+
self.empty.display = False
|
|
363
|
+
|
|
364
|
+
def compose(self) -> ComposeResult:
|
|
365
|
+
yield self.bar
|
|
366
|
+
yield self.table
|
|
367
|
+
yield self.empty
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
4
|
+
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label, Static
|
|
9
|
+
|
|
10
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import BindingType
|
|
15
|
+
from textual.widget import Widget
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComposeFormDialog(ModalScreen[None]):
|
|
19
|
+
"""A form dialog's modal screen: its title, text and form, then Cancel and OK.
|
|
20
|
+
|
|
21
|
+
Installed once and pushed at each opening, so its form keeps its widgets: a screen
|
|
22
|
+
dismissed can't be pushed again. It reports the user's answer (`answered`: "accept",
|
|
23
|
+
"reject", or "dismiss" for Escape) without closing; its renderer pops it once the
|
|
24
|
+
view closes the dialog, which stays open when its form refuses OK.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
ComposeFormDialog {
|
|
29
|
+
align: center middle;
|
|
30
|
+
}
|
|
31
|
+
ComposeFormDialog > VerticalScroll {
|
|
32
|
+
width: 70%;
|
|
33
|
+
max-width: 100;
|
|
34
|
+
height: auto;
|
|
35
|
+
max-height: 90%;
|
|
36
|
+
padding: 1 2;
|
|
37
|
+
border: thick $primary 80%;
|
|
38
|
+
background: $surface;
|
|
39
|
+
}
|
|
40
|
+
ComposeFormDialog .title {
|
|
41
|
+
text-style: bold;
|
|
42
|
+
margin-bottom: 1;
|
|
43
|
+
}
|
|
44
|
+
ComposeFormDialog Horizontal {
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: auto;
|
|
47
|
+
align-horizontal: right;
|
|
48
|
+
margin-top: 1;
|
|
49
|
+
}
|
|
50
|
+
ComposeFormDialog Button {
|
|
51
|
+
margin-left: 1;
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "dismiss_dialog", "Close")]
|
|
56
|
+
|
|
57
|
+
def __init__(self, form: Widget) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.title_label = Label(classes="title", markup=False)
|
|
60
|
+
self.text_label = Static(markup=False)
|
|
61
|
+
self.form = form
|
|
62
|
+
self.accept_button = Button("", variant="primary", id="accept")
|
|
63
|
+
self.reject_button = Button("", id="reject")
|
|
64
|
+
self.answered: Signal[str] = Signal()
|
|
65
|
+
#: the screen's widgets are mounted, its form's among them: once, as it's installed
|
|
66
|
+
self.mounted: SignalNoArgs = SignalNoArgs()
|
|
67
|
+
|
|
68
|
+
def compose(self) -> ComposeResult:
|
|
69
|
+
with VerticalScroll():
|
|
70
|
+
yield self.title_label
|
|
71
|
+
yield self.text_label
|
|
72
|
+
yield self.form
|
|
73
|
+
with Horizontal():
|
|
74
|
+
yield self.reject_button
|
|
75
|
+
yield self.accept_button
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
self.mounted.emit()
|
|
79
|
+
|
|
80
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
81
|
+
event.stop()
|
|
82
|
+
self.answered.emit("accept" if event.button is self.accept_button else "reject")
|
|
83
|
+
|
|
84
|
+
def action_dismiss_dialog(self) -> None:
|
|
85
|
+
if not self.reject_button.disabled: # a disabled dialog can't be closed
|
|
86
|
+
self.answered.emit("dismiss")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from textual.widgets import Input
|
|
6
|
+
|
|
7
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from textual import events
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ComposeInput(Input):
|
|
14
|
+
"""An `Input` that re-exposes its messages as plain composeui `Signal`s.
|
|
15
|
+
|
|
16
|
+
Textual dispatches `Input.Changed`/`Input.Submitted` by calling `on_input_changed`/
|
|
17
|
+
`on_input_submitted` on the widget itself (and its DOM ancestors); a renderer isn't
|
|
18
|
+
part of the DOM, so it can't bind to those directly. Bridging them to `Signal`s here
|
|
19
|
+
lets `TextualInputRenderer` 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`): as Input's
|
|
23
|
+
# own `-invalid`
|
|
24
|
+
DEFAULT_CSS = """
|
|
25
|
+
ComposeInput.-row-invalid {
|
|
26
|
+
border: tall $error 60%;
|
|
27
|
+
&:focus {
|
|
28
|
+
border: tall $error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
self.value_changed: Signal[str] = Signal()
|
|
36
|
+
self.value_submitted: SignalNoArgs = SignalNoArgs()
|
|
37
|
+
|
|
38
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
39
|
+
self.value_changed.emit(event.value)
|
|
40
|
+
|
|
41
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
42
|
+
self.value_submitted.emit()
|
|
43
|
+
|
|
44
|
+
def on_blur(self, event: events.Blur) -> None:
|
|
45
|
+
"""Match Qt's `QLineEdit.editingFinished`, which fires on Enter *or* blur.
|
|
46
|
+
|
|
47
|
+
`Input.Submitted` alone only covers Enter; without this, tabbing/clicking
|
|
48
|
+
away from a field (instead of pressing Enter) never fires `editing_finished`.
|
|
49
|
+
"""
|
|
50
|
+
self.value_submitted.emit()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ComposeList(Vertical):
|
|
7
|
+
"""A list's views one above the other, as tall as they are.
|
|
8
|
+
|
|
9
|
+
Its collapsed collapsibles (an expander per record) leave out the line under their
|
|
10
|
+
title, a lone one's room for its content: one line apart, many records stay in view.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
ComposeList {
|
|
15
|
+
height: auto;
|
|
16
|
+
& > Collapsible.-collapsed {
|
|
17
|
+
padding-bottom: 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
"""
|