SaaS-FrameWork 0.1.1__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.
- components/__init__.py +119 -0
- components/buttons/DEx_BtnFAB.py +37 -0
- components/buttons/DEx_BtnFilled.py +20 -0
- components/buttons/DEx_BtnGhost.py +20 -0
- components/buttons/DEx_BtnIcon.py +18 -0
- components/buttons/DEx_BtnPrimary.py +21 -0
- components/buttons/DEx_BtnSegmented.py +45 -0
- components/buttons/DEx_BtnText.py +19 -0
- components/buttons/DEx_BtnTonal.py +20 -0
- components/buttons/DEx_MenuBar.py +61 -0
- components/buttons/DEx_PopupMenu.py +43 -0
- components/buttons/__init__.py +0 -0
- components/cards/DEx_Card.py +23 -0
- components/cards/DEx_ContactCard.py +52 -0
- components/cards/DEx_MetricCard.py +36 -0
- components/cards/__init__.py +0 -0
- components/data/DEx_DataTable.py +82 -0
- components/data/DEx_ExpansionPanel.py +39 -0
- components/data/DEx_ExpansionTile.py +29 -0
- components/data/DEx_GridView.py +65 -0
- components/data/DEx_ListTile.py +27 -0
- components/data/DEx_ListView.py +65 -0
- components/data/DEx_PipelineColumn.py +74 -0
- components/data/DEx_TreeView.py +536 -0
- components/data/__init__.py +0 -0
- components/feedback/DEx_AlertDialog.py +26 -0
- components/feedback/DEx_Banner.py +19 -0
- components/feedback/DEx_BottomSheet.py +37 -0
- components/feedback/DEx_Notify.py +15 -0
- components/feedback/DEx_ProgressBar.py +34 -0
- components/feedback/DEx_ProgressRing.py +52 -0
- components/feedback/DEx_Shimmer.py +84 -0
- components/feedback/DEx_Tooltip.py +24 -0
- components/feedback/__init__.py +0 -0
- components/inputs/DEx_AutoComplete.py +29 -0
- components/inputs/DEx_Checkbox.py +27 -0
- components/inputs/DEx_Chip.py +33 -0
- components/inputs/DEx_DatePicker.py +72 -0
- components/inputs/DEx_Dropdown.py +25 -0
- components/inputs/DEx_FilePicker.py +70 -0
- components/inputs/DEx_Radio.py +37 -0
- components/inputs/DEx_SearchBar.py +37 -0
- components/inputs/DEx_SearchField.py +9 -0
- components/inputs/DEx_Slider.py +49 -0
- components/inputs/DEx_Switch.py +22 -0
- components/inputs/DEx_TextField.py +29 -0
- components/inputs/DEx_TimePicker.py +71 -0
- components/inputs/__init__.py +0 -0
- components/layout/DEx_AnimatedSwitcher.py +43 -0
- components/layout/DEx_AppBar.py +48 -0
- components/layout/DEx_AppShell.py +80 -0
- components/layout/DEx_Draggable.py +67 -0
- components/layout/DEx_GestureDetector.py +52 -0
- components/layout/DEx_Header.py +57 -0
- components/layout/DEx_NavItem.py +61 -0
- components/layout/DEx_NavigationBar.py +30 -0
- components/layout/DEx_NavigationDrawer.py +60 -0
- components/layout/DEx_NavigationRail.py +42 -0
- components/layout/DEx_ResponsiveRow.py +42 -0
- components/layout/DEx_Sidebar.py +131 -0
- components/layout/DEx_Stack.py +73 -0
- components/layout/DEx_Tabs.py +110 -0
- components/layout/__init__.py +0 -0
- components/metadata.py +98 -0
- components/primitives/DEx_Avatar.py +87 -0
- components/primitives/DEx_Badge.py +18 -0
- components/primitives/DEx_Divider.py +13 -0
- components/primitives/DEx_Icon.py +46 -0
- components/primitives/DEx_Image.py +55 -0
- components/primitives/DEx_Markdown.py +21 -0
- components/primitives/DEx_SectionEyebrow.py +22 -0
- components/primitives/DEx_StatusDot.py +8 -0
- components/primitives/__init__.py +0 -0
- components/project.py +23 -0
- components/theme/DEx_ColorThemes.py +375 -0
- components/theme/DEx_Theme.py +136 -0
- components/theme/__init__.py +4 -0
- components/versioning.py +103 -0
- saas_framework-0.1.1.dist-info/METADATA +450 -0
- saas_framework-0.1.1.dist-info/RECORD +82 -0
- saas_framework-0.1.1.dist-info/WHEEL +5 -0
- saas_framework-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Hierarchical tree view with search, selection, and expand/collapse controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import flet as ft
|
|
11
|
+
|
|
12
|
+
from components.theme.DEx_Theme import Colors, Spacing
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
COMPONENT_VERSION = "0.0.1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DEx_TreeView(ft.Column):
|
|
19
|
+
"""Versioned hierarchical tree with recursive search and single-node selection.
|
|
20
|
+
|
|
21
|
+
`DEx_TreeView` accepts data as a Python list, a dict with a `menu` key, a JSON
|
|
22
|
+
string, or a JSON file path. Each node supports `label`, `type`, `icon`, and
|
|
23
|
+
`children`. Labels using the format `(ID) - Text` render the ID separately so
|
|
24
|
+
menus and module trees stay scannable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
menu_path: str | Path | None = None,
|
|
30
|
+
menu_data: dict | list | str | None = None,
|
|
31
|
+
*,
|
|
32
|
+
on_select: Callable[["_DEx_TreeNode"], None] | None = None,
|
|
33
|
+
on_insert: Callable[["DEx_TreeView"], None] | None = None,
|
|
34
|
+
show_icon: bool = True,
|
|
35
|
+
indent_step: int = 15,
|
|
36
|
+
toggle_size: int = 18,
|
|
37
|
+
dex_id: str = "",
|
|
38
|
+
on_dex_ready: Callable[["DEx_TreeView"], None] | None = None,
|
|
39
|
+
**kwargs: Any,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(**kwargs)
|
|
42
|
+
self.menu_path = Path(menu_path) if menu_path else None
|
|
43
|
+
self._menu_data_provided = menu_data is not None
|
|
44
|
+
self.menu_data = [] if menu_data is None else menu_data
|
|
45
|
+
self.external_on_select = on_select
|
|
46
|
+
self.external_on_insert = on_insert
|
|
47
|
+
self.show_icon = show_icon
|
|
48
|
+
self.indent_step = indent_step
|
|
49
|
+
self.toggle_size = toggle_size
|
|
50
|
+
self.dex_id = dex_id
|
|
51
|
+
self._on_dex_ready = on_dex_ready
|
|
52
|
+
|
|
53
|
+
self.selected_node: dict[str, _DEx_TreeNode | None] = {"node": None}
|
|
54
|
+
self.root_nodes: list[_DEx_TreeNode] = []
|
|
55
|
+
self.tree_col = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
|
56
|
+
self.tree_view = ft.Row([self.tree_col], scroll=ft.ScrollMode.AUTO, expand=True)
|
|
57
|
+
|
|
58
|
+
self.search_field = self._build_search_field()
|
|
59
|
+
self._build_ui()
|
|
60
|
+
self.reload()
|
|
61
|
+
|
|
62
|
+
def reload(self) -> None:
|
|
63
|
+
"""Reload tree data from `menu_data` or `menu_path` and rebuild root nodes."""
|
|
64
|
+
data = self._load_data()
|
|
65
|
+
menu_items = self._extract_menu_list(data)
|
|
66
|
+
self.root_nodes = self._build_tree(menu_items, level=0)
|
|
67
|
+
self.tree_col.controls = [node.control for node in self.root_nodes]
|
|
68
|
+
self.selected_node["node"] = None
|
|
69
|
+
self._update_tree()
|
|
70
|
+
|
|
71
|
+
def collapse_all(self, e: ft.ControlEvent | None = None) -> None:
|
|
72
|
+
"""Collapse every tree node."""
|
|
73
|
+
for node in self.root_nodes:
|
|
74
|
+
_set_expanded_node(node, False)
|
|
75
|
+
self._update_tree()
|
|
76
|
+
|
|
77
|
+
def expand_all(self, e: ft.ControlEvent | None = None) -> None:
|
|
78
|
+
"""Expand every tree node."""
|
|
79
|
+
for node in self.root_nodes:
|
|
80
|
+
_set_expanded_node(node, True)
|
|
81
|
+
self._update_tree()
|
|
82
|
+
|
|
83
|
+
def collapse_selected(self, e: ft.ControlEvent | None = None) -> None:
|
|
84
|
+
"""Collapse the currently selected node."""
|
|
85
|
+
node = self.selected_node["node"]
|
|
86
|
+
if node is None:
|
|
87
|
+
return
|
|
88
|
+
node.expanded = False
|
|
89
|
+
node.refresh()
|
|
90
|
+
self._update_tree()
|
|
91
|
+
|
|
92
|
+
def expand_selected(self, e: ft.ControlEvent | None = None) -> None:
|
|
93
|
+
"""Expand the currently selected node."""
|
|
94
|
+
node = self.selected_node["node"]
|
|
95
|
+
if node is None:
|
|
96
|
+
return
|
|
97
|
+
node.expanded = True
|
|
98
|
+
node.refresh()
|
|
99
|
+
self._update_tree()
|
|
100
|
+
|
|
101
|
+
def refresh_selected(self, e: ft.ControlEvent | None = None) -> None:
|
|
102
|
+
"""Refresh visual state for the currently selected node."""
|
|
103
|
+
node = self.selected_node["node"]
|
|
104
|
+
if node is None:
|
|
105
|
+
return
|
|
106
|
+
node.refresh()
|
|
107
|
+
self._update_tree()
|
|
108
|
+
|
|
109
|
+
def insert(self, e: ft.ControlEvent | None = None) -> None:
|
|
110
|
+
"""Run the optional insert callback for the host application."""
|
|
111
|
+
if self.external_on_insert:
|
|
112
|
+
self.external_on_insert(self)
|
|
113
|
+
|
|
114
|
+
def on_keyboard_event(self, e: ft.KeyboardEvent) -> None:
|
|
115
|
+
"""Move selection with arrow up/down and page up/down keys."""
|
|
116
|
+
key = (e.key or "").lower()
|
|
117
|
+
if key in ("arrow up", "up"):
|
|
118
|
+
self._move_selection(-1)
|
|
119
|
+
elif key in ("arrow down", "down"):
|
|
120
|
+
self._move_selection(1)
|
|
121
|
+
elif key in ("page up", "pageup"):
|
|
122
|
+
self._move_selection(-10)
|
|
123
|
+
elif key in ("page down", "pagedown"):
|
|
124
|
+
self._move_selection(10)
|
|
125
|
+
|
|
126
|
+
def did_mount(self) -> None:
|
|
127
|
+
"""Notify the host when Flet mounts the component."""
|
|
128
|
+
super().did_mount()
|
|
129
|
+
if self._on_dex_ready:
|
|
130
|
+
self._on_dex_ready(self)
|
|
131
|
+
|
|
132
|
+
def _build_ui(self) -> None:
|
|
133
|
+
self.controls = [
|
|
134
|
+
ft.Container(
|
|
135
|
+
content=ft.Row(
|
|
136
|
+
[
|
|
137
|
+
self.search_field,
|
|
138
|
+
self._icon_button(ft.Icons.CLEAR_ROUNDED, "Limpar busca", self._clear_search),
|
|
139
|
+
],
|
|
140
|
+
spacing=Spacing.SM,
|
|
141
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
142
|
+
),
|
|
143
|
+
padding=ft.Padding(Spacing.SM, Spacing.SM, Spacing.SM, Spacing.XS),
|
|
144
|
+
),
|
|
145
|
+
ft.Container(height=1, bgcolor=Colors.BORDER),
|
|
146
|
+
ft.Container(
|
|
147
|
+
content=self.tree_view,
|
|
148
|
+
alignment=ft.Alignment(-1, -1),
|
|
149
|
+
bgcolor=Colors.BG_DEEP,
|
|
150
|
+
expand=True,
|
|
151
|
+
),
|
|
152
|
+
ft.Container(height=1, bgcolor=Colors.BORDER),
|
|
153
|
+
ft.Container(
|
|
154
|
+
content=ft.Row(
|
|
155
|
+
[
|
|
156
|
+
self._icon_button(ft.Icons.UNFOLD_LESS_ROUNDED, "Contrair tudo", self.collapse_all),
|
|
157
|
+
self._icon_button(ft.Icons.KEYBOARD_ARROW_UP_ROUNDED, "Contrair selecionado", self.collapse_selected),
|
|
158
|
+
self._icon_button(ft.Icons.REFRESH_ROUNDED, "Atualizar selecionado", self.refresh_selected),
|
|
159
|
+
self._icon_button(ft.Icons.KEYBOARD_ARROW_DOWN_ROUNDED, "Expandir selecionado", self.expand_selected),
|
|
160
|
+
self._icon_button(ft.Icons.UNFOLD_MORE_ROUNDED, "Expandir tudo", self.expand_all),
|
|
161
|
+
ft.Container(expand=True),
|
|
162
|
+
self._icon_button(ft.Icons.ADD_ROUNDED, "Novo registro", self.insert, size=24, box_size=40),
|
|
163
|
+
],
|
|
164
|
+
spacing=Spacing.SM,
|
|
165
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
166
|
+
),
|
|
167
|
+
padding=ft.Padding(Spacing.SM, Spacing.XS, Spacing.SM, Spacing.SM),
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
self.spacing = 0
|
|
171
|
+
self.expand = True
|
|
172
|
+
|
|
173
|
+
def _build_search_field(self) -> ft.TextField:
|
|
174
|
+
return ft.TextField(
|
|
175
|
+
hint_text="Faca uma pesquisa...",
|
|
176
|
+
height=34,
|
|
177
|
+
text_size=12,
|
|
178
|
+
text_style=ft.TextStyle(color=Colors.TEXT_PRIMARY, size=12),
|
|
179
|
+
hint_style=ft.TextStyle(color=Colors.TEXT_MUTED, size=12),
|
|
180
|
+
content_padding=ft.Padding(8, 0, 8, 0),
|
|
181
|
+
prefix_icon=ft.Icons.SEARCH_ROUNDED,
|
|
182
|
+
border_color=Colors.BORDER,
|
|
183
|
+
focused_border_color=Colors.ACCENT,
|
|
184
|
+
bgcolor=Colors.BG_ELEVATED,
|
|
185
|
+
border_radius=8,
|
|
186
|
+
on_change=lambda e: self._apply_search(),
|
|
187
|
+
expand=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _icon_button(
|
|
191
|
+
self,
|
|
192
|
+
icon: ft.IconData,
|
|
193
|
+
tooltip: str,
|
|
194
|
+
on_click: Callable | None,
|
|
195
|
+
*,
|
|
196
|
+
size: int = 16,
|
|
197
|
+
box_size: int = 28,
|
|
198
|
+
) -> ft.Container:
|
|
199
|
+
return ft.Container(
|
|
200
|
+
width=box_size,
|
|
201
|
+
height=box_size,
|
|
202
|
+
content=ft.IconButton(
|
|
203
|
+
icon=icon,
|
|
204
|
+
icon_size=size,
|
|
205
|
+
icon_color=Colors.TEXT_SECONDARY,
|
|
206
|
+
tooltip=tooltip,
|
|
207
|
+
on_click=on_click,
|
|
208
|
+
style=ft.ButtonStyle(padding=0),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _load_data(self) -> dict | list:
|
|
213
|
+
if self._menu_data_provided:
|
|
214
|
+
return self._normalize_menu_data(self.menu_data)
|
|
215
|
+
if self.menu_path:
|
|
216
|
+
return json.loads(self.menu_path.read_text(encoding="utf-8-sig"))
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
def _normalize_menu_data(self, menu_data: dict | list | str) -> dict | list:
|
|
220
|
+
if isinstance(menu_data, str):
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(menu_data)
|
|
223
|
+
except json.JSONDecodeError as exc:
|
|
224
|
+
raise ValueError("menu_data must be valid JSON.") from exc
|
|
225
|
+
if isinstance(menu_data, (dict, list)):
|
|
226
|
+
return menu_data
|
|
227
|
+
raise TypeError("menu_data must be a dict, list, or JSON string.")
|
|
228
|
+
|
|
229
|
+
def _extract_menu_list(self, data: dict | list) -> list:
|
|
230
|
+
if isinstance(data, list):
|
|
231
|
+
return data
|
|
232
|
+
if isinstance(data, dict):
|
|
233
|
+
return data.get("menu", [])
|
|
234
|
+
raise TypeError("Tree data must be a list or a dict with a menu key.")
|
|
235
|
+
|
|
236
|
+
def _build_tree(self, data: list, level: int = 0) -> list["_DEx_TreeNode"]:
|
|
237
|
+
nodes: list[_DEx_TreeNode] = []
|
|
238
|
+
for item in data:
|
|
239
|
+
children = self._build_tree(item.get("children", []), level + 1)
|
|
240
|
+
nodes.append(
|
|
241
|
+
_DEx_TreeNode(
|
|
242
|
+
label=item["label"],
|
|
243
|
+
node_type=item.get("type", "file"),
|
|
244
|
+
icon=item.get("icon"),
|
|
245
|
+
children=children,
|
|
246
|
+
level=level,
|
|
247
|
+
on_select=self._on_select,
|
|
248
|
+
show_icon=self.show_icon,
|
|
249
|
+
indent_step=self.indent_step,
|
|
250
|
+
toggle_size=self.toggle_size,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return nodes
|
|
254
|
+
|
|
255
|
+
def _get_visible_nodes(self) -> list["_DEx_TreeNode"]:
|
|
256
|
+
nodes: list[_DEx_TreeNode] = []
|
|
257
|
+
|
|
258
|
+
def walk(items: list[_DEx_TreeNode]) -> None:
|
|
259
|
+
for node in items:
|
|
260
|
+
nodes.append(node)
|
|
261
|
+
if node.expanded and node.children:
|
|
262
|
+
walk(node.children)
|
|
263
|
+
|
|
264
|
+
walk(self.root_nodes)
|
|
265
|
+
return nodes
|
|
266
|
+
|
|
267
|
+
def _on_select(self, node: "_DEx_TreeNode") -> None:
|
|
268
|
+
current = self.selected_node["node"]
|
|
269
|
+
if current is not None and current is not node:
|
|
270
|
+
current.is_selected = False
|
|
271
|
+
current.refresh()
|
|
272
|
+
node.is_selected = True
|
|
273
|
+
self.selected_node["node"] = node
|
|
274
|
+
node.refresh()
|
|
275
|
+
if self.external_on_select:
|
|
276
|
+
self.external_on_select(node)
|
|
277
|
+
|
|
278
|
+
def _move_selection(self, delta: int) -> None:
|
|
279
|
+
if self.search_field.value:
|
|
280
|
+
return
|
|
281
|
+
visible = self._get_visible_nodes()
|
|
282
|
+
if not visible:
|
|
283
|
+
return
|
|
284
|
+
current = self.selected_node["node"]
|
|
285
|
+
index = visible.index(current) if current in visible else 0
|
|
286
|
+
next_index = max(0, min(len(visible) - 1, index + delta))
|
|
287
|
+
self._on_select(visible[next_index])
|
|
288
|
+
self._update_tree()
|
|
289
|
+
|
|
290
|
+
def _apply_search(self) -> None:
|
|
291
|
+
self.tree_col.controls = _filter_tree(self.root_nodes, self.search_field.value)
|
|
292
|
+
self._update_tree()
|
|
293
|
+
|
|
294
|
+
def _clear_search(self, e: ft.ControlEvent | None = None) -> None:
|
|
295
|
+
self.search_field.value = ""
|
|
296
|
+
self._apply_search()
|
|
297
|
+
if _is_mounted(self.search_field):
|
|
298
|
+
self.search_field.update()
|
|
299
|
+
|
|
300
|
+
def _update_tree(self) -> None:
|
|
301
|
+
if _is_mounted(self.tree_col):
|
|
302
|
+
self.tree_col.update()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class _DEx_TreeNode:
|
|
306
|
+
"""Internal node model used by `DEx_TreeView`."""
|
|
307
|
+
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
label: str,
|
|
311
|
+
node_type: str,
|
|
312
|
+
icon: str | ft.IconData | None = None,
|
|
313
|
+
children: list["_DEx_TreeNode"] | None = None,
|
|
314
|
+
level: int = 0,
|
|
315
|
+
on_select: Callable[["_DEx_TreeNode"], None] | None = None,
|
|
316
|
+
show_icon: bool = True,
|
|
317
|
+
indent_step: int = 15,
|
|
318
|
+
toggle_size: int = 18,
|
|
319
|
+
) -> None:
|
|
320
|
+
self.label = label
|
|
321
|
+
self.node_type = node_type
|
|
322
|
+
self.icon = icon
|
|
323
|
+
self.children = children or []
|
|
324
|
+
self.level = level
|
|
325
|
+
self.on_select = on_select
|
|
326
|
+
self.expanded = level == 0
|
|
327
|
+
self.is_selected = False
|
|
328
|
+
self.show_icon = show_icon
|
|
329
|
+
self.indent_step = indent_step
|
|
330
|
+
self.toggle_size = toggle_size
|
|
331
|
+
|
|
332
|
+
self._children_column = ft.Column(spacing=0)
|
|
333
|
+
self._toggle_icon: ft.Icon | None = None
|
|
334
|
+
self._item_icon: ft.Icon | None = None
|
|
335
|
+
self._label_text_controls: list[tuple[ft.Text, str]] = []
|
|
336
|
+
self.control = self._build()
|
|
337
|
+
|
|
338
|
+
def toggle(self, e: ft.ControlEvent | None = None) -> None:
|
|
339
|
+
self.expanded = not self.expanded
|
|
340
|
+
self.refresh()
|
|
341
|
+
|
|
342
|
+
def select(self, e: ft.ControlEvent | None = None) -> None:
|
|
343
|
+
if self.on_select:
|
|
344
|
+
self.on_select(self)
|
|
345
|
+
|
|
346
|
+
def refresh(self, update: bool = True) -> None:
|
|
347
|
+
self._children_column.controls = [child.control for child in self.children] if self.expanded else []
|
|
348
|
+
self._sync_visuals()
|
|
349
|
+
if update and _is_mounted(self.control):
|
|
350
|
+
self.control.update()
|
|
351
|
+
|
|
352
|
+
def _build(self) -> ft.Column:
|
|
353
|
+
header = ft.Container(
|
|
354
|
+
height=28,
|
|
355
|
+
content=ft.Row(
|
|
356
|
+
self._build_row_controls(),
|
|
357
|
+
spacing=3,
|
|
358
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
359
|
+
),
|
|
360
|
+
bgcolor=_selected_node_bgcolor() if self.is_selected else None,
|
|
361
|
+
border_radius=6,
|
|
362
|
+
padding=ft.Padding(0, 0, 6, 0),
|
|
363
|
+
on_click=self.select,
|
|
364
|
+
ink=True,
|
|
365
|
+
)
|
|
366
|
+
self._children_column.controls = [child.control for child in self.children] if self.expanded else []
|
|
367
|
+
return ft.Column([header, self._children_column], spacing=0)
|
|
368
|
+
|
|
369
|
+
def _build_row_controls(self) -> list[ft.Control]:
|
|
370
|
+
has_children = bool(self.children)
|
|
371
|
+
controls: list[ft.Control] = [ft.Container(width=self.indent_step * self.level)]
|
|
372
|
+
|
|
373
|
+
if has_children:
|
|
374
|
+
self._toggle_icon = ft.Icon(self._toggle_icon_name(), size=15, color=Colors.TEXT_SECONDARY)
|
|
375
|
+
controls.append(
|
|
376
|
+
ft.Container(
|
|
377
|
+
width=self.toggle_size,
|
|
378
|
+
height=self.toggle_size,
|
|
379
|
+
alignment=ft.Alignment(0, 0),
|
|
380
|
+
content=self._toggle_icon,
|
|
381
|
+
on_click=self.toggle,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
controls.append(ft.Container(width=self.toggle_size, height=self.toggle_size))
|
|
386
|
+
|
|
387
|
+
if self.show_icon:
|
|
388
|
+
self._item_icon = ft.Icon(self._resolve_item_icon(has_children), size=15, color=Colors.DATA)
|
|
389
|
+
controls.append(self._item_icon)
|
|
390
|
+
|
|
391
|
+
controls.append(self._build_label_control())
|
|
392
|
+
return controls
|
|
393
|
+
|
|
394
|
+
def _build_label_control(self) -> ft.Control:
|
|
395
|
+
match = re.match(r"^\(([^)]+)\)\s*-\s*(.*)$", self.label)
|
|
396
|
+
if match:
|
|
397
|
+
id_text = ft.Text(f"({match.group(1)})", size=11, color=Colors.DATA, font_family="monospace")
|
|
398
|
+
label_text = ft.Text(match.group(2), size=13, color=Colors.TEXT_PRIMARY)
|
|
399
|
+
self._label_text_controls = [(id_text, "id"), (label_text, "label")]
|
|
400
|
+
return ft.Row(
|
|
401
|
+
[id_text, label_text],
|
|
402
|
+
spacing=5,
|
|
403
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
404
|
+
)
|
|
405
|
+
label_text = ft.Text(self.label, size=13, color=Colors.TEXT_PRIMARY)
|
|
406
|
+
self._label_text_controls = [(label_text, "label")]
|
|
407
|
+
return label_text
|
|
408
|
+
|
|
409
|
+
def _toggle_icon_name(self) -> ft.IconData:
|
|
410
|
+
return ft.Icons.KEYBOARD_ARROW_DOWN_ROUNDED if self.expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED
|
|
411
|
+
|
|
412
|
+
def _resolve_item_icon(self, has_children: bool) -> ft.IconData:
|
|
413
|
+
if self.icon:
|
|
414
|
+
if isinstance(self.icon, str):
|
|
415
|
+
icon_key = self.icon.strip().lower()
|
|
416
|
+
if icon_key == "folder":
|
|
417
|
+
return ft.Icons.FOLDER_OPEN_OUTLINED if self.expanded else ft.Icons.FOLDER_OUTLINED
|
|
418
|
+
if icon_key == "docs":
|
|
419
|
+
return ft.Icons.DESCRIPTION_OUTLINED
|
|
420
|
+
|
|
421
|
+
resolved = getattr(ft.Icons, self.icon.strip().upper(), None)
|
|
422
|
+
if resolved is not None:
|
|
423
|
+
return resolved
|
|
424
|
+
return self.icon
|
|
425
|
+
|
|
426
|
+
if has_children:
|
|
427
|
+
return ft.Icons.FOLDER_OPEN_OUTLINED if self.expanded else ft.Icons.FOLDER_OUTLINED
|
|
428
|
+
return ft.Icons.DESCRIPTION_OUTLINED
|
|
429
|
+
|
|
430
|
+
def _sync_visuals(self) -> None:
|
|
431
|
+
has_children = bool(self.children)
|
|
432
|
+
selected_bgcolor = _selected_node_bgcolor()
|
|
433
|
+
selected_color = _readable_text_color(selected_bgcolor)
|
|
434
|
+
if self._toggle_icon is not None:
|
|
435
|
+
self._toggle_icon.name = self._toggle_icon_name()
|
|
436
|
+
self._toggle_icon.color = selected_color if self.is_selected else Colors.TEXT_SECONDARY
|
|
437
|
+
if self._item_icon is not None:
|
|
438
|
+
self._item_icon.name = self._resolve_item_icon(has_children)
|
|
439
|
+
self._item_icon.color = selected_color if self.is_selected else Colors.DATA
|
|
440
|
+
|
|
441
|
+
header = self.control.controls[0]
|
|
442
|
+
header.bgcolor = selected_bgcolor if self.is_selected else None
|
|
443
|
+
header.border = ft.Border.all(1, Colors.ACCENT) if self.is_selected else None
|
|
444
|
+
|
|
445
|
+
for text_control, role in self._label_text_controls:
|
|
446
|
+
if self.is_selected:
|
|
447
|
+
text_control.color = selected_color
|
|
448
|
+
elif role == "id":
|
|
449
|
+
text_control.color = Colors.DATA
|
|
450
|
+
else:
|
|
451
|
+
text_control.color = Colors.TEXT_PRIMARY
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _is_mounted(control: ft.Control) -> bool:
|
|
455
|
+
return getattr(control, "_Control__uid", None) is not None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _selected_node_bgcolor() -> str:
|
|
459
|
+
return Colors.ACCENT
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _readable_text_color(bgcolor: str) -> str:
|
|
463
|
+
black_ratio = _contrast_ratio(bgcolor, "#000000")
|
|
464
|
+
white_ratio = _contrast_ratio(bgcolor, "#FFFFFF")
|
|
465
|
+
return "#000000" if black_ratio >= white_ratio else "#FFFFFF"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _contrast_ratio(color_a: str, color_b: str) -> float:
|
|
469
|
+
lum_a = _relative_luminance(color_a)
|
|
470
|
+
lum_b = _relative_luminance(color_b)
|
|
471
|
+
lighter = max(lum_a, lum_b)
|
|
472
|
+
darker = min(lum_a, lum_b)
|
|
473
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _relative_luminance(hex_color: str) -> float:
|
|
477
|
+
red, green, blue = _hex_to_rgb(hex_color)
|
|
478
|
+
values = []
|
|
479
|
+
for channel in (red, green, blue):
|
|
480
|
+
value = channel / 255
|
|
481
|
+
values.append(value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4)
|
|
482
|
+
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
486
|
+
value = hex_color.strip().lstrip("#")[:6]
|
|
487
|
+
if len(value) != 6:
|
|
488
|
+
return (0, 0, 0)
|
|
489
|
+
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _set_expanded_node(node: _DEx_TreeNode, expanded: bool) -> None:
|
|
493
|
+
node.expanded = expanded
|
|
494
|
+
for child in node.children:
|
|
495
|
+
_set_expanded_node(child, expanded)
|
|
496
|
+
node.refresh(update=False)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _filter_tree(nodes: list[_DEx_TreeNode], query: str | None) -> list[ft.Control]:
|
|
500
|
+
query = (query or "").strip().lower()
|
|
501
|
+
if not query:
|
|
502
|
+
for node in nodes:
|
|
503
|
+
_reset_node(node)
|
|
504
|
+
return [node.control for node in nodes]
|
|
505
|
+
|
|
506
|
+
matches: list[ft.Control] = []
|
|
507
|
+
for node in nodes:
|
|
508
|
+
if _apply_filter(node, query):
|
|
509
|
+
matches.append(node.control)
|
|
510
|
+
return matches
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _reset_node(node: _DEx_TreeNode) -> None:
|
|
514
|
+
for child in node.children:
|
|
515
|
+
_reset_node(child)
|
|
516
|
+
node._children_column.controls = [child.control for child in node.children] if node.expanded else []
|
|
517
|
+
node._sync_visuals()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _apply_filter(node: _DEx_TreeNode, query: str) -> bool:
|
|
521
|
+
matched_children = [child for child in node.children if _apply_filter(child, query)]
|
|
522
|
+
is_match = query in node.label.lower()
|
|
523
|
+
|
|
524
|
+
if matched_children:
|
|
525
|
+
node.expanded = True
|
|
526
|
+
node._children_column.controls = [child.control for child in matched_children]
|
|
527
|
+
node._sync_visuals()
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
if is_match:
|
|
531
|
+
node.expanded = False
|
|
532
|
+
node._children_column.controls = []
|
|
533
|
+
node._sync_visuals()
|
|
534
|
+
return True
|
|
535
|
+
|
|
536
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import flet as ft
|
|
2
|
+
from components.theme.DEx_Theme import Colors, Spacing
|
|
3
|
+
|
|
4
|
+
COMPONENT_VERSION = "0.0.1"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def DEx_AlertDialog(
|
|
8
|
+
title: str,
|
|
9
|
+
content: ft.Control | str | None = None,
|
|
10
|
+
actions: list[ft.Control] | None = None,
|
|
11
|
+
on_dismiss=None,
|
|
12
|
+
) -> ft.AlertDialog:
|
|
13
|
+
"""Modal dialog for confirmations, alerts and short forms."""
|
|
14
|
+
body = (
|
|
15
|
+
ft.Text(content, size=13, color=Colors.TEXT_SECONDARY)
|
|
16
|
+
if isinstance(content, str)
|
|
17
|
+
else content
|
|
18
|
+
)
|
|
19
|
+
return ft.AlertDialog(
|
|
20
|
+
title=ft.Text(title, size=16, weight=ft.FontWeight.W_600, color=Colors.TEXT_PRIMARY),
|
|
21
|
+
content=body,
|
|
22
|
+
actions=actions or [],
|
|
23
|
+
on_dismiss=on_dismiss,
|
|
24
|
+
bgcolor=Colors.BG_ELEVATED,
|
|
25
|
+
shape=ft.RoundedRectangleBorder(radius=Spacing.CARD_BORDER_RADIUS),
|
|
26
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import flet as ft
|
|
2
|
+
from components.theme.DEx_Theme import Colors, Spacing
|
|
3
|
+
|
|
4
|
+
COMPONENT_VERSION = "0.0.1"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def DEx_Banner(
|
|
8
|
+
message: str,
|
|
9
|
+
icon: str = ft.Icons.INFO_OUTLINE_ROUNDED,
|
|
10
|
+
actions: list[ft.Control] | None = None,
|
|
11
|
+
bgcolor: str | None = None,
|
|
12
|
+
) -> ft.Banner:
|
|
13
|
+
"""Persistent informational banner displayed below the AppBar."""
|
|
14
|
+
return ft.Banner(
|
|
15
|
+
content=ft.Text(message, size=13, color=Colors.TEXT_PRIMARY),
|
|
16
|
+
leading=ft.Icon(icon, color=Colors.ACCENT, size=20),
|
|
17
|
+
actions=actions or [],
|
|
18
|
+
bgcolor=bgcolor or Colors.BG_ELEVATED,
|
|
19
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import flet as ft
|
|
2
|
+
from components.theme.DEx_Theme import Colors, Spacing
|
|
3
|
+
|
|
4
|
+
COMPONENT_VERSION = "0.0.2"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def DEx_BottomSheet(
|
|
8
|
+
content: ft.Control,
|
|
9
|
+
on_dismiss=None,
|
|
10
|
+
) -> ft.BottomSheet:
|
|
11
|
+
"""Bottom sheet that slides up from the bottom edge of the screen."""
|
|
12
|
+
return ft.BottomSheet(
|
|
13
|
+
content=ft.Container(
|
|
14
|
+
content=content,
|
|
15
|
+
bgcolor=Colors.BG_ELEVATED,
|
|
16
|
+
border_radius=ft.BorderRadius(
|
|
17
|
+
top_left=Spacing.CARD_BORDER_RADIUS,
|
|
18
|
+
top_right=Spacing.CARD_BORDER_RADIUS,
|
|
19
|
+
bottom_left=0,
|
|
20
|
+
bottom_right=0,
|
|
21
|
+
),
|
|
22
|
+
padding=ft.Padding.symmetric(horizontal=Spacing.LG, vertical=Spacing.MD),
|
|
23
|
+
),
|
|
24
|
+
bgcolor=Colors.BG_ELEVATED,
|
|
25
|
+
dismissible=True,
|
|
26
|
+
draggable=True,
|
|
27
|
+
show_drag_handle=True,
|
|
28
|
+
scrollable=True,
|
|
29
|
+
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
|
|
30
|
+
shape=ft.RoundedRectangleBorder(
|
|
31
|
+
radius=ft.BorderRadius(
|
|
32
|
+
top_left=Spacing.CARD_BORDER_RADIUS,
|
|
33
|
+
top_right=Spacing.CARD_BORDER_RADIUS,
|
|
34
|
+
)
|
|
35
|
+
),
|
|
36
|
+
on_dismiss=on_dismiss,
|
|
37
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import flet as ft
|
|
2
|
+
from components.theme.DEx_Theme import Colors
|
|
3
|
+
|
|
4
|
+
COMPONENT_VERSION = "0.0.1"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def DEx_Notify(page: ft.Page, message: str, success: bool = True) -> None:
|
|
8
|
+
"""Show the versioned DEx snackbar notification on the current page."""
|
|
9
|
+
page.snack_bar = ft.SnackBar(
|
|
10
|
+
content=ft.Text(message, color=Colors.TEXT_PRIMARY, size=13),
|
|
11
|
+
bgcolor=Colors.SUCCESS if success else Colors.DANGER,
|
|
12
|
+
duration=3000,
|
|
13
|
+
)
|
|
14
|
+
page.snack_bar.open = True
|
|
15
|
+
page.update()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import flet as ft
|
|
2
|
+
from components.theme.DEx_Theme import Colors, Spacing
|
|
3
|
+
|
|
4
|
+
COMPONENT_VERSION = "0.0.1"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def DEx_ProgressBar(
|
|
8
|
+
value: float | None = None,
|
|
9
|
+
label: str = "",
|
|
10
|
+
show_percent: bool = False,
|
|
11
|
+
color: str | None = None,
|
|
12
|
+
bgcolor: str | None = None,
|
|
13
|
+
height: int = 6,
|
|
14
|
+
) -> ft.Column:
|
|
15
|
+
"""Horizontal progress bar with optional label and percentage."""
|
|
16
|
+
bar = ft.ProgressBar(
|
|
17
|
+
value=value,
|
|
18
|
+
color=color or Colors.ACCENT,
|
|
19
|
+
bgcolor=bgcolor or Colors.BG_ELEVATED,
|
|
20
|
+
height=height,
|
|
21
|
+
border_radius=height,
|
|
22
|
+
)
|
|
23
|
+
if not label and not show_percent:
|
|
24
|
+
return ft.Column([bar], spacing=0, tight=True)
|
|
25
|
+
|
|
26
|
+
pct_text = f"{int((value or 0) * 100)}%" if show_percent and value is not None else ""
|
|
27
|
+
header = ft.Row(
|
|
28
|
+
[
|
|
29
|
+
ft.Text(label, size=12, color=Colors.TEXT_SECONDARY) if label else ft.Container(),
|
|
30
|
+
ft.Text(pct_text, size=12, color=Colors.TEXT_MUTED) if pct_text else ft.Container(),
|
|
31
|
+
],
|
|
32
|
+
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
|
33
|
+
)
|
|
34
|
+
return ft.Column([header, bar], spacing=Spacing.XS, tight=True)
|