structui 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.
- structui/__init__.py +7 -0
- structui/app.py +15 -0
- structui/cli.py +20 -0
- structui/file_picker.py +95 -0
- structui/parser.py +51 -0
- structui/schema.py +117 -0
- structui/state.py +138 -0
- structui/ui.py +458 -0
- structui-0.1.0.dist-info/METADATA +54 -0
- structui-0.1.0.dist-info/RECORD +13 -0
- structui-0.1.0.dist-info/WHEEL +5 -0
- structui-0.1.0.dist-info/entry_points.txt +2 -0
- structui-0.1.0.dist-info/top_level.txt +1 -0
structui/__init__.py
ADDED
structui/app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from nicegui import ui
|
|
2
|
+
from structui.ui import StructUI
|
|
3
|
+
from structui.state import AppState
|
|
4
|
+
from structui.schema import SchemaManager
|
|
5
|
+
|
|
6
|
+
def run_app(data_dir: str = ".", schema_filepath: str = ".structui_schema.yaml", port: int = 8080):
|
|
7
|
+
schema_manager = SchemaManager(schema_filepath)
|
|
8
|
+
app_state = AppState(data_dir, schema_manager)
|
|
9
|
+
|
|
10
|
+
ui_instance = StructUI(app_state, schema_manager)
|
|
11
|
+
|
|
12
|
+
@ui.page('/')
|
|
13
|
+
def main_page():
|
|
14
|
+
ui_instance.render()
|
|
15
|
+
ui.run(port=port, title="StructUI Editor", show=False, reload=False)
|
structui/cli.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from structui.app import run_app
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
parser = argparse.ArgumentParser(description="StructUI Configuration Editor")
|
|
7
|
+
parser.add_argument("--dir", type=str, default=".", help="Directory containing config files")
|
|
8
|
+
parser.add_argument("--schema", type=str, default=".structui_schema.yaml", help="Path to schema file")
|
|
9
|
+
parser.add_argument("--port", type=int, default=8080, help="Port to run the UI on (default: 8080)")
|
|
10
|
+
|
|
11
|
+
args = parser.parse_args()
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
run_app(data_dir=args.dir, schema_filepath=args.schema, port=args.port)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
print(f"Error starting StructUI: {e}", file=sys.stderr)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
if __name__ in {"__main__", "__mp_main__"}:
|
|
20
|
+
main()
|
structui/file_picker.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from nicegui import events, ui
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocalFilePicker(ui.dialog):
|
|
9
|
+
|
|
10
|
+
def __init__(self, directory: str, *,
|
|
11
|
+
upper_limit: Optional[str] = ..., multiple: bool = False, show_hidden_files: bool = False,
|
|
12
|
+
dirs_only: bool = False) -> None:
|
|
13
|
+
"""Local File Picker
|
|
14
|
+
|
|
15
|
+
This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running.
|
|
16
|
+
|
|
17
|
+
:param directory: The directory to start in.
|
|
18
|
+
:param upper_limit: The directory to stop navigating up (e.g. the root directory).
|
|
19
|
+
:param multiple: Whether to allow multiple files to be selected.
|
|
20
|
+
:param show_hidden_files: Whether to show hidden files.
|
|
21
|
+
:param dirs_only: Whether to only show directories.
|
|
22
|
+
"""
|
|
23
|
+
super().__init__()
|
|
24
|
+
|
|
25
|
+
self.path = Path(directory).expanduser().resolve()
|
|
26
|
+
if upper_limit is None:
|
|
27
|
+
self.upper_limit = None
|
|
28
|
+
else:
|
|
29
|
+
self.upper_limit = Path(directory if upper_limit == ... else upper_limit).expanduser().resolve()
|
|
30
|
+
self.show_hidden_files = show_hidden_files
|
|
31
|
+
self.dirs_only = dirs_only
|
|
32
|
+
|
|
33
|
+
with self, ui.card():
|
|
34
|
+
self.add_drives_toggle()
|
|
35
|
+
self.grid = ui.aggrid({
|
|
36
|
+
'columnDefs': [{'field': 'name', 'headerName': 'File'}],
|
|
37
|
+
'rowSelection': 'multiple' if multiple else 'single',
|
|
38
|
+
}, html_columns=[0]).classes('w-96').on('cellDoubleClicked', self.handle_double_click)
|
|
39
|
+
with ui.row().classes('w-full justify-end'):
|
|
40
|
+
ui.button('Cancel', on_click=self.close).props('outline')
|
|
41
|
+
ui.button('Ok', on_click=self._handle_ok)
|
|
42
|
+
self.update_grid()
|
|
43
|
+
|
|
44
|
+
def add_drives_toggle(self):
|
|
45
|
+
if platform.system() == 'Windows':
|
|
46
|
+
import win32api
|
|
47
|
+
drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
|
|
48
|
+
self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
|
|
49
|
+
|
|
50
|
+
def update_drive(self):
|
|
51
|
+
self.path = Path(self.drives_toggle.value).expanduser()
|
|
52
|
+
self.update_grid()
|
|
53
|
+
|
|
54
|
+
def update_grid(self) -> None:
|
|
55
|
+
paths = list(self.path.iterdir())
|
|
56
|
+
if not self.show_hidden_files:
|
|
57
|
+
paths = [p for p in paths if not p.name.startswith('.')]
|
|
58
|
+
if self.dirs_only:
|
|
59
|
+
paths = [p for p in paths if p.is_dir()]
|
|
60
|
+
paths.sort(key=lambda p: p.name.lower())
|
|
61
|
+
paths.sort(key=lambda p: not p.is_dir())
|
|
62
|
+
|
|
63
|
+
self.grid.options['rowData'] = [
|
|
64
|
+
{
|
|
65
|
+
'name': f'📁 <strong>{p.name}</strong>' if p.is_dir() else p.name,
|
|
66
|
+
'path': str(p),
|
|
67
|
+
}
|
|
68
|
+
for p in paths
|
|
69
|
+
]
|
|
70
|
+
if self.upper_limit is None or self.path != self.upper_limit:
|
|
71
|
+
self.grid.options['rowData'].insert(0, {
|
|
72
|
+
'name': '📁 <strong>..</strong>',
|
|
73
|
+
'path': str(self.path.parent),
|
|
74
|
+
})
|
|
75
|
+
self.grid.update()
|
|
76
|
+
|
|
77
|
+
def handle_double_click(self, e: events.GenericEventArguments) -> None:
|
|
78
|
+
self.path = Path(e.args['data']['path'])
|
|
79
|
+
if self.path.is_dir():
|
|
80
|
+
self.update_grid()
|
|
81
|
+
else:
|
|
82
|
+
self.submit([str(self.path)])
|
|
83
|
+
|
|
84
|
+
async def _handle_ok(self):
|
|
85
|
+
try:
|
|
86
|
+
rows = await self.grid.get_selected_rows()
|
|
87
|
+
except TimeoutError:
|
|
88
|
+
rows = []
|
|
89
|
+
|
|
90
|
+
if rows:
|
|
91
|
+
self.submit([r['path'] for r in rows])
|
|
92
|
+
elif self.dirs_only:
|
|
93
|
+
self.submit([str(self.path)])
|
|
94
|
+
else:
|
|
95
|
+
ui.notify('No file selected.')
|
structui/parser.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Dict, Any
|
|
6
|
+
|
|
7
|
+
class DataParser(ABC):
|
|
8
|
+
"""Abstract base class for format-agnostic configuration parsing."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def load(self, filepath: str) -> Any:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def save(self, filepath: str, data: Any):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class YamlParser(DataParser):
|
|
19
|
+
def load(self, filepath: str) -> Any:
|
|
20
|
+
try:
|
|
21
|
+
with open(filepath, 'r') as f:
|
|
22
|
+
return yaml.safe_load(f)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f"YAML Load Error ({filepath}): {e}")
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def save(self, filepath: str, data: Any):
|
|
28
|
+
with open(filepath, 'w') as f:
|
|
29
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
30
|
+
|
|
31
|
+
class JsonParser(DataParser):
|
|
32
|
+
def load(self, filepath: str) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
with open(filepath, 'r') as f:
|
|
35
|
+
return json.load(f)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
print(f"JSON Load Error ({filepath}): {e}")
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def save(self, filepath: str, data: Any):
|
|
41
|
+
with open(filepath, 'w') as f:
|
|
42
|
+
json.dump(data, f, indent=4)
|
|
43
|
+
|
|
44
|
+
def get_parser(filepath: str) -> DataParser:
|
|
45
|
+
"""Factory method to resolve the correct parser by file extension."""
|
|
46
|
+
if filepath.endswith(('.yaml', '.yml')):
|
|
47
|
+
return YamlParser()
|
|
48
|
+
elif filepath.endswith('.json'):
|
|
49
|
+
return JsonParser()
|
|
50
|
+
# Easily extensible to XML, CSV, etc.
|
|
51
|
+
return YamlParser()
|
structui/schema.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from .parser import get_parser
|
|
4
|
+
|
|
5
|
+
class SchemaManager:
|
|
6
|
+
"""Handles schema metadata parsing, defaults resolution, and validation logic."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, schema_filepath: str):
|
|
9
|
+
self.schema_filepath = schema_filepath
|
|
10
|
+
self.schema_meta: Dict[str, Any] = {}
|
|
11
|
+
self._load_schema()
|
|
12
|
+
|
|
13
|
+
def _load_schema(self):
|
|
14
|
+
if os.path.exists(self.schema_filepath):
|
|
15
|
+
parser = get_parser(self.schema_filepath)
|
|
16
|
+
loaded_schema = parser.load(self.schema_filepath)
|
|
17
|
+
self.schema_meta = loaded_schema if loaded_schema is not None else {}
|
|
18
|
+
else:
|
|
19
|
+
print(f"Schema file {self.schema_filepath} not found. Using empty schema.")
|
|
20
|
+
|
|
21
|
+
def get_meta(self, key: str) -> Dict[str, Any]:
|
|
22
|
+
"""Safely fetch metadata for a property key."""
|
|
23
|
+
return self.schema_meta.get(key, {})
|
|
24
|
+
|
|
25
|
+
def get_default_val_for_type(self, type_str: str) -> Any:
|
|
26
|
+
"""Returns a sensible default value based on the given schema type."""
|
|
27
|
+
if type_str == 'boolean': return False
|
|
28
|
+
if type_str in ['integer', 'number', 'float']: return 0
|
|
29
|
+
if type_str in ['dict', 'container']: return {}
|
|
30
|
+
if type_str == 'list': return []
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
def prefill_required(self, schema_key: str) -> Dict[str, Any]:
|
|
34
|
+
"""Scans the schema and creates a dictionary containing all REQUIRED child properties pre-filled."""
|
|
35
|
+
new_val = {}
|
|
36
|
+
allowed = self.get_meta(schema_key).get('allowed_children', [])
|
|
37
|
+
for child_key in allowed:
|
|
38
|
+
child_meta = self.get_meta(child_key)
|
|
39
|
+
if child_meta.get('required', False):
|
|
40
|
+
child_type = child_meta.get('type', 'string')
|
|
41
|
+
if child_type in ['container', 'dict']:
|
|
42
|
+
new_val[child_key] = self.prefill_required(child_key)
|
|
43
|
+
else:
|
|
44
|
+
new_val[child_key] = self.get_default_val_for_type(child_type)
|
|
45
|
+
return new_val
|
|
46
|
+
|
|
47
|
+
def get_schema_key_for_path(self, path: str, root_data: Any) -> str:
|
|
48
|
+
"""Resolves the active schema definition key for a data path location."""
|
|
49
|
+
if path == "root":
|
|
50
|
+
return "root"
|
|
51
|
+
parts = path.split('/')[1:]
|
|
52
|
+
current_schema_key = None
|
|
53
|
+
curr_data = root_data
|
|
54
|
+
|
|
55
|
+
for p in parts:
|
|
56
|
+
if curr_data is not None:
|
|
57
|
+
if isinstance(curr_data, list) and p.isdigit():
|
|
58
|
+
try: curr_data = curr_data[int(p)]
|
|
59
|
+
except IndexError: curr_data = None
|
|
60
|
+
elif isinstance(curr_data, dict):
|
|
61
|
+
curr_data = curr_data.get(p)
|
|
62
|
+
else:
|
|
63
|
+
curr_data = None
|
|
64
|
+
|
|
65
|
+
if p.endswith(('.yaml', '.yml', '.json')):
|
|
66
|
+
current_schema_key = os.path.splitext(p)[0]
|
|
67
|
+
elif p.isdigit():
|
|
68
|
+
if current_schema_key and current_schema_key in self.schema_meta:
|
|
69
|
+
meta = self.get_meta(current_schema_key)
|
|
70
|
+
if 'list_item_types' in meta:
|
|
71
|
+
best_match = None
|
|
72
|
+
max_overlap = -1
|
|
73
|
+
if isinstance(curr_data, dict):
|
|
74
|
+
for t in meta['list_item_types']:
|
|
75
|
+
allowed = set(self.get_meta(t).get('allowed_children', []))
|
|
76
|
+
overlap = len(allowed.intersection(curr_data.keys()))
|
|
77
|
+
if overlap > max_overlap:
|
|
78
|
+
max_overlap = overlap
|
|
79
|
+
best_match = t
|
|
80
|
+
current_schema_key = best_match if best_match else meta['list_item_types'][0]
|
|
81
|
+
else:
|
|
82
|
+
current_schema_key = meta.get('list_item_type', current_schema_key)
|
|
83
|
+
else:
|
|
84
|
+
current_schema_key = p
|
|
85
|
+
return current_schema_key
|
|
86
|
+
|
|
87
|
+
def get_label_key_for_schema(self, schema_key: str) -> str:
|
|
88
|
+
"""Finds which sub-property should be dynamically used as the naming label for UI containers."""
|
|
89
|
+
if schema_key and schema_key in self.schema_meta:
|
|
90
|
+
meta = self.get_meta(schema_key)
|
|
91
|
+
if 'label_key' in meta:
|
|
92
|
+
return meta['label_key']
|
|
93
|
+
for child in meta.get('allowed_children', []):
|
|
94
|
+
if child in self.schema_meta and self.schema_meta[child].get('is_label', False):
|
|
95
|
+
return child
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def get_item_label(self, item_data: Any, item_path: str, root_data: Any, default_label: str) -> str:
|
|
99
|
+
"""Agnostically determines the display label for an object via schema rules."""
|
|
100
|
+
if not isinstance(item_data, dict):
|
|
101
|
+
return default_label
|
|
102
|
+
|
|
103
|
+
schema_key = self.get_schema_key_for_path(item_path, root_data)
|
|
104
|
+
label_key = self.get_label_key_for_schema(schema_key)
|
|
105
|
+
|
|
106
|
+
if label_key and label_key in item_data:
|
|
107
|
+
return str(item_data[label_key])
|
|
108
|
+
|
|
109
|
+
for k, v in item_data.items():
|
|
110
|
+
if 'name' in str(k).lower() and isinstance(v, (str, int)):
|
|
111
|
+
return str(v)
|
|
112
|
+
|
|
113
|
+
for k, v in item_data.items():
|
|
114
|
+
if isinstance(v, str):
|
|
115
|
+
return v
|
|
116
|
+
|
|
117
|
+
return default_label
|
structui/state.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Dict, Any, List
|
|
5
|
+
from .schema import SchemaManager
|
|
6
|
+
from .parser import get_parser
|
|
7
|
+
|
|
8
|
+
class AppState:
|
|
9
|
+
"""Manages raw config data, memory states, transactions, and undo/redo stacks."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data_dir: str, schema_manager: SchemaManager):
|
|
12
|
+
self.data_dir = data_dir
|
|
13
|
+
self.schema_manager = schema_manager
|
|
14
|
+
|
|
15
|
+
self.config_data: Dict[str, Any] = {}
|
|
16
|
+
self.history: List[Dict[str, Any]] = []
|
|
17
|
+
self.history_index: int = -1
|
|
18
|
+
self.last_saved_index: int = -1
|
|
19
|
+
self.is_dirty: bool = False
|
|
20
|
+
|
|
21
|
+
self.load_files()
|
|
22
|
+
|
|
23
|
+
def load_files(self):
|
|
24
|
+
"""Loads all supported formatting files in the source directory."""
|
|
25
|
+
self.config_data = {}
|
|
26
|
+
files = glob.glob(os.path.join(self.data_dir, "*.yaml")) + \
|
|
27
|
+
glob.glob(os.path.join(self.data_dir, "*.yml")) + \
|
|
28
|
+
glob.glob(os.path.join(self.data_dir, "*.json"))
|
|
29
|
+
|
|
30
|
+
for filepath in files:
|
|
31
|
+
filename = os.path.basename(filepath)
|
|
32
|
+
if filename == os.path.basename(self.schema_manager.schema_filepath):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
parser = get_parser(filepath)
|
|
36
|
+
data = parser.load(filepath)
|
|
37
|
+
|
|
38
|
+
if data is None:
|
|
39
|
+
# Fill structure based on declared schema
|
|
40
|
+
schema_key = os.path.splitext(filename)[0]
|
|
41
|
+
file_type = self.schema_manager.get_meta(schema_key).get('type', 'dict')
|
|
42
|
+
data = [] if file_type == 'list' else {}
|
|
43
|
+
|
|
44
|
+
self.config_data[filename] = data
|
|
45
|
+
|
|
46
|
+
self.history = [copy.deepcopy(self.config_data)]
|
|
47
|
+
self.history_index = 0
|
|
48
|
+
self.last_saved_index = 0
|
|
49
|
+
self.is_dirty = False
|
|
50
|
+
|
|
51
|
+
def commit(self):
|
|
52
|
+
"""Saves a memory snapshot into the history stack."""
|
|
53
|
+
self.history = self.history[:self.history_index + 1]
|
|
54
|
+
self.history.append(copy.deepcopy(self.config_data))
|
|
55
|
+
|
|
56
|
+
if len(self.history) > 100:
|
|
57
|
+
self.history.pop(0)
|
|
58
|
+
if self.last_saved_index > 0:
|
|
59
|
+
self.last_saved_index -= 1
|
|
60
|
+
elif self.last_saved_index == 0:
|
|
61
|
+
self.last_saved_index = -1
|
|
62
|
+
else:
|
|
63
|
+
self.history_index += 1
|
|
64
|
+
self.is_dirty = (self.history_index != self.last_saved_index)
|
|
65
|
+
|
|
66
|
+
def undo(self) -> bool:
|
|
67
|
+
"""Reverts local modification to a previous epoch."""
|
|
68
|
+
if self.history_index > 0:
|
|
69
|
+
self.history_index -= 1
|
|
70
|
+
self.config_data = copy.deepcopy(self.history[self.history_index])
|
|
71
|
+
self.is_dirty = (self.history_index != self.last_saved_index)
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def redo(self) -> bool:
|
|
76
|
+
"""Restores a previous undo."""
|
|
77
|
+
if self.history_index < len(self.history) - 1:
|
|
78
|
+
self.history_index += 1
|
|
79
|
+
self.config_data = copy.deepcopy(self.history[self.history_index])
|
|
80
|
+
self.is_dirty = (self.history_index != self.last_saved_index)
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def get_data_by_path(self, path: str) -> Any:
|
|
85
|
+
"""Returns the local data node associated with the UI tree path string."""
|
|
86
|
+
if path == "root":
|
|
87
|
+
return self.config_data
|
|
88
|
+
|
|
89
|
+
keys = path.split('/')[1:]
|
|
90
|
+
curr = self.config_data
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
for key in keys:
|
|
94
|
+
if curr is None: return None
|
|
95
|
+
if isinstance(curr, list):
|
|
96
|
+
curr = curr[int(key)]
|
|
97
|
+
elif isinstance(curr, dict):
|
|
98
|
+
curr = curr.get(key)
|
|
99
|
+
else:
|
|
100
|
+
return None
|
|
101
|
+
return curr
|
|
102
|
+
except (IndexError, ValueError, KeyError, AttributeError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def set_data_by_path(self, path: str, property_key: str, new_value: Any):
|
|
106
|
+
"""Mutates a targeted property value on the underlying tree."""
|
|
107
|
+
curr = self.get_data_by_path(path)
|
|
108
|
+
if isinstance(curr, dict):
|
|
109
|
+
curr[property_key] = new_value
|
|
110
|
+
elif isinstance(curr, list):
|
|
111
|
+
curr[int(property_key)] = new_value
|
|
112
|
+
self.is_dirty = (self.history_index != self.last_saved_index)
|
|
113
|
+
|
|
114
|
+
def save_all_to_disk(self):
|
|
115
|
+
"""Dispatches save operations to agnostic parsers and handles raw deletion tracking."""
|
|
116
|
+
schema_base = os.path.basename(self.schema_manager.schema_filepath)
|
|
117
|
+
existing_logical_files = [
|
|
118
|
+
f for f in os.listdir(self.data_dir)
|
|
119
|
+
if f.endswith(('.yaml', '.yml', '.json')) and f != schema_base
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for filename, data in self.config_data.items():
|
|
123
|
+
filepath = os.path.join(self.data_dir, filename)
|
|
124
|
+
try:
|
|
125
|
+
parser = get_parser(filepath)
|
|
126
|
+
parser.save(filepath, data)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f"Error saving {filename}: {e}")
|
|
129
|
+
|
|
130
|
+
for f in existing_logical_files:
|
|
131
|
+
if f not in self.config_data:
|
|
132
|
+
try:
|
|
133
|
+
os.remove(os.path.join(self.data_dir, f))
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"Error removing {f}: {e}")
|
|
136
|
+
|
|
137
|
+
self.last_saved_index = self.history_index
|
|
138
|
+
self.is_dirty = False
|
structui/ui.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from nicegui import app, ui
|
|
4
|
+
from typing import Dict, Any, List
|
|
5
|
+
from .state import AppState
|
|
6
|
+
from .schema import SchemaManager
|
|
7
|
+
from .file_picker import LocalFilePicker
|
|
8
|
+
|
|
9
|
+
class StructUI:
|
|
10
|
+
"""The central view abstraction for managing the hierarchical NiceGUI visualization."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, state: AppState, schema_manager: SchemaManager):
|
|
13
|
+
self.state = state
|
|
14
|
+
self.schema_manager = schema_manager
|
|
15
|
+
|
|
16
|
+
self.selected_path = {"value": "root"}
|
|
17
|
+
self.tree = None
|
|
18
|
+
self.editor_scroll_area = None
|
|
19
|
+
self.footer_pane = None
|
|
20
|
+
self.dark_mode = None
|
|
21
|
+
self.save_btn = None
|
|
22
|
+
|
|
23
|
+
def get_allowed_options(self, path: str, data_node: Any) -> List[Dict[str, str]]:
|
|
24
|
+
schema_key = self.schema_manager.get_schema_key_for_path(path, self.state.config_data)
|
|
25
|
+
meta = self.schema_manager.get_meta(schema_key) if schema_key else {}
|
|
26
|
+
allowed_from_schema = meta.get('allowed_children', [])
|
|
27
|
+
allowed_options = []
|
|
28
|
+
|
|
29
|
+
if isinstance(data_node, dict):
|
|
30
|
+
for child in allowed_from_schema:
|
|
31
|
+
if child not in data_node:
|
|
32
|
+
allowed_options.append({'label': f"Add {child.replace('_', ' ').title()}", 'type': 'dict_key', 'key': child})
|
|
33
|
+
else:
|
|
34
|
+
child_meta = self.schema_manager.get_meta(child)
|
|
35
|
+
if isinstance(data_node[child], list) and child_meta.get('type') in ['container', 'list']:
|
|
36
|
+
if 'list_item_types' in child_meta:
|
|
37
|
+
for item_type in child_meta['list_item_types']:
|
|
38
|
+
allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()} to {child}", 'type': 'list_item_append', 'key': child, 'item_type': item_type})
|
|
39
|
+
else:
|
|
40
|
+
item_type = child_meta.get('list_item_type', child)
|
|
41
|
+
allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()} to {child}", 'type': 'list_item_append', 'key': child, 'item_type': item_type})
|
|
42
|
+
|
|
43
|
+
if not meta.get('restrict_custom_keys', False):
|
|
44
|
+
allowed_options.append({'label': "Add Custom File" if path == "root" else "Add Custom Key", 'type': 'custom_dict'})
|
|
45
|
+
|
|
46
|
+
elif isinstance(data_node, list):
|
|
47
|
+
meta = self.schema_manager.get_meta(schema_key) if schema_key else {}
|
|
48
|
+
if 'list_item_types' in meta:
|
|
49
|
+
for item_type in meta['list_item_types']:
|
|
50
|
+
allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()}", 'type': 'list_item_typed', 'key': item_type})
|
|
51
|
+
elif 'list_item_type' in meta:
|
|
52
|
+
item_type = meta['list_item_type']
|
|
53
|
+
allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').upper()}", 'type': 'list_item_typed', 'key': item_type})
|
|
54
|
+
else:
|
|
55
|
+
allowed_options.append({'label': "Add New Item", 'type': 'list_item'})
|
|
56
|
+
|
|
57
|
+
return allowed_options
|
|
58
|
+
|
|
59
|
+
def build_tree_nodes(self, data: Any, path: str = "root", name: str = "Configurations") -> Dict[str, Any]:
|
|
60
|
+
node = {'id': path, 'label': name}
|
|
61
|
+
children = []
|
|
62
|
+
node['allowed'] = self.get_allowed_options(path, data)
|
|
63
|
+
has_prims = False
|
|
64
|
+
|
|
65
|
+
if isinstance(data, dict):
|
|
66
|
+
for k, v in data.items():
|
|
67
|
+
if not isinstance(v, (dict, list)):
|
|
68
|
+
has_prims = True
|
|
69
|
+
elif isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v):
|
|
70
|
+
if self.schema_manager.get_meta(str(k)).get('type') not in ['container', 'list']:
|
|
71
|
+
has_prims = True
|
|
72
|
+
|
|
73
|
+
if isinstance(v, (dict, list)):
|
|
74
|
+
if isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v):
|
|
75
|
+
if self.schema_manager.get_meta(str(k)).get('type') not in ['container', 'list']:
|
|
76
|
+
continue
|
|
77
|
+
children.append(self.build_tree_nodes(v, f"{path}/{k}", str(k)))
|
|
78
|
+
elif isinstance(data, list):
|
|
79
|
+
for i, v in enumerate(data):
|
|
80
|
+
if isinstance(v, (dict, list)):
|
|
81
|
+
label = self.schema_manager.get_item_label(v, f"{path}/{i}", self.state.config_data, f"[{i}]")
|
|
82
|
+
children.append(self.build_tree_nodes(v, f"{path}/{i}", label))
|
|
83
|
+
|
|
84
|
+
if has_prims:
|
|
85
|
+
node['icon'], node['color'] = 'settings', 'blue-8'
|
|
86
|
+
else:
|
|
87
|
+
node['icon'], node['color'] = 'folder', 'primary'
|
|
88
|
+
|
|
89
|
+
if not children and not has_prims:
|
|
90
|
+
node['icon'], node['color'] = 'folder_open', 'grey-5'
|
|
91
|
+
|
|
92
|
+
if children:
|
|
93
|
+
node['children'] = children
|
|
94
|
+
|
|
95
|
+
return node
|
|
96
|
+
|
|
97
|
+
def refresh_tree_and_editor(self):
|
|
98
|
+
if self.tree is not None:
|
|
99
|
+
new_nodes = [self.build_tree_nodes(self.state.config_data)]
|
|
100
|
+
curr_expanded = set(self.tree._props.get('expanded', []))
|
|
101
|
+
|
|
102
|
+
if self.selected_path["value"]:
|
|
103
|
+
parts = self.selected_path["value"].split('/')
|
|
104
|
+
for i in range(1, len(parts) + 1):
|
|
105
|
+
curr_expanded.add("/".join(parts[:i]))
|
|
106
|
+
|
|
107
|
+
self.tree._props['nodes'] = new_nodes
|
|
108
|
+
self.tree._props['expanded'] = list(curr_expanded)
|
|
109
|
+
|
|
110
|
+
if self.selected_path["value"]:
|
|
111
|
+
self.tree._props['selected'] = self.selected_path["value"]
|
|
112
|
+
|
|
113
|
+
self.tree.update()
|
|
114
|
+
|
|
115
|
+
if not self.selected_path["value"]:
|
|
116
|
+
self.selected_path["value"] = "root"
|
|
117
|
+
|
|
118
|
+
self.update_save_btn_state()
|
|
119
|
+
|
|
120
|
+
self.draw_editor(self.selected_path["value"])
|
|
121
|
+
|
|
122
|
+
def update_save_btn_state(self):
|
|
123
|
+
if getattr(self, 'save_btn', None):
|
|
124
|
+
if getattr(self.state, 'is_dirty', False):
|
|
125
|
+
self.save_btn._props['color'] = 'warning'
|
|
126
|
+
self.save_btn.tooltip('Unsaved Changes Available!')
|
|
127
|
+
else:
|
|
128
|
+
self.save_btn._props['color'] = 'primary'
|
|
129
|
+
self.save_btn.tooltip('Save Configurations')
|
|
130
|
+
self.save_btn.update()
|
|
131
|
+
|
|
132
|
+
self.draw_editor(self.selected_path["value"])
|
|
133
|
+
|
|
134
|
+
def update_footer(self, prop_key=None):
|
|
135
|
+
self.footer_pane.clear()
|
|
136
|
+
with self.footer_pane:
|
|
137
|
+
if not prop_key:
|
|
138
|
+
ui.label("Help & Metadata").classes('text-lg font-bold text-gray-700 dark:text-gray-300')
|
|
139
|
+
ui.label("Select a property in the editor to see its description and type.").classes('text-gray-500 dark:text-gray-400 italic')
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
meta = self.schema_manager.get_meta(prop_key)
|
|
143
|
+
if not meta:
|
|
144
|
+
meta = {"type": "unknown", "desc": "No description available for this property."}
|
|
145
|
+
|
|
146
|
+
with ui.row().classes('items-center gap-2 mb-2'):
|
|
147
|
+
ui.icon('info', size='sm', color='primary')
|
|
148
|
+
ui.label(prop_key).classes('text-lg font-bold text-blue-800 dark:text-blue-300 font-mono')
|
|
149
|
+
ui.badge(meta.get('type', 'unknown'), color='blue-200').classes('text-blue-900 ml-2')
|
|
150
|
+
|
|
151
|
+
is_req = meta.get('required', False)
|
|
152
|
+
req_color = "red-100 text-red-800" if is_req else "green-100 text-green-800"
|
|
153
|
+
ui.badge("Required" if is_req else "Optional").classes(f'ml-2 {req_color} font-bold border border-current')
|
|
154
|
+
ui.markdown(meta.get('desc', 'No description provided.')).classes('text-gray-700 dark:text-gray-300 text-md ml-8')
|
|
155
|
+
|
|
156
|
+
def handle_add_node(self, path: str, option: Dict[str, str]):
|
|
157
|
+
data_node = self.state.get_data_by_path(path)
|
|
158
|
+
opt_type = option['type']
|
|
159
|
+
|
|
160
|
+
if opt_type == 'dict_key':
|
|
161
|
+
if isinstance(data_node, dict):
|
|
162
|
+
k = option.get('key')
|
|
163
|
+
meta_type = self.schema_manager.get_meta(k).get('type')
|
|
164
|
+
if meta_type == 'list':
|
|
165
|
+
data_node[k] = []
|
|
166
|
+
elif meta_type in ['container', 'dict']:
|
|
167
|
+
data_node[k] = {}
|
|
168
|
+
elif meta_type == 'bool':
|
|
169
|
+
data_node[k] = False
|
|
170
|
+
elif meta_type in ['int', 'number', 'float']:
|
|
171
|
+
data_node[k] = 0
|
|
172
|
+
else:
|
|
173
|
+
data_node[k] = ""
|
|
174
|
+
elif opt_type == 'custom_dict':
|
|
175
|
+
if isinstance(data_node, dict):
|
|
176
|
+
with ui.dialog() as dialog, ui.card().classes('min-w-[300px]'):
|
|
177
|
+
def perform_add(dyn_key, dyn_type):
|
|
178
|
+
if dyn_key:
|
|
179
|
+
if dyn_type == 'dict': data_node[dyn_key] = {}
|
|
180
|
+
elif dyn_type == 'list': data_node[dyn_key] = []
|
|
181
|
+
elif dyn_type == 'string': data_node[dyn_key] = ""
|
|
182
|
+
elif dyn_type == 'integer': data_node[dyn_key] = 0
|
|
183
|
+
elif dyn_type == 'boolean': data_node[dyn_key] = False
|
|
184
|
+
dialog.close()
|
|
185
|
+
self.state.commit()
|
|
186
|
+
self.refresh_tree_and_editor()
|
|
187
|
+
ui.label('Enter new key name:').classes('font-bold')
|
|
188
|
+
inp = ui.input().classes('w-full')
|
|
189
|
+
ui.label('Select Type:').classes('font-bold mt-2')
|
|
190
|
+
type_sel = ui.select(['string', 'integer', 'boolean', 'list', 'dict'], value='string').classes('w-full')
|
|
191
|
+
ui.button('Add', on_click=lambda: perform_add(inp.value, type_sel.value)).classes('mt-4 w-full')
|
|
192
|
+
dialog.open()
|
|
193
|
+
return
|
|
194
|
+
elif opt_type in ['list_item', 'list_item_typed']:
|
|
195
|
+
if isinstance(data_node, list):
|
|
196
|
+
data_node.append({})
|
|
197
|
+
elif opt_type == 'list_item_append':
|
|
198
|
+
if isinstance(data_node, dict):
|
|
199
|
+
list_key = option.get('key')
|
|
200
|
+
if list_key in data_node and isinstance(data_node[list_key], list):
|
|
201
|
+
data_node[list_key].append({})
|
|
202
|
+
|
|
203
|
+
self.state.commit()
|
|
204
|
+
self.refresh_tree_and_editor()
|
|
205
|
+
|
|
206
|
+
def draw_editor(self, path: str):
|
|
207
|
+
if not path:
|
|
208
|
+
path = "root"
|
|
209
|
+
self.selected_path["value"] = path
|
|
210
|
+
self.editor_scroll_area.clear()
|
|
211
|
+
self.update_footer(None)
|
|
212
|
+
|
|
213
|
+
data_node = self.state.get_data_by_path(path)
|
|
214
|
+
if data_node is None:
|
|
215
|
+
with self.editor_scroll_area:
|
|
216
|
+
ui.label("This node no longer exists or was deleted.").classes('text-red-500 mt-10 text-lg font-bold')
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
with self.editor_scroll_area:
|
|
220
|
+
# INTERACTIVE BREADCRUMBS
|
|
221
|
+
parts = path.split('/')
|
|
222
|
+
with ui.row().classes('items-center gap-2 mb-6 w-full flex-wrap'):
|
|
223
|
+
current_path = []
|
|
224
|
+
for i, p in enumerate(parts):
|
|
225
|
+
current_path.append(p)
|
|
226
|
+
full_path = "/".join(current_path)
|
|
227
|
+
|
|
228
|
+
def make_breadcrumb_nav(nav_p=full_path):
|
|
229
|
+
return lambda e: (self.selected_path.update({"value": nav_p}), self.refresh_tree_and_editor())
|
|
230
|
+
|
|
231
|
+
ui.label("Root" if p == "root" else p).classes('text-blue-600 dark:text-blue-400 hover:text-blue-800 cursor-pointer font-bold text-lg hover:underline').on('click', make_breadcrumb_nav())
|
|
232
|
+
if i < len(parts) - 1:
|
|
233
|
+
ui.icon('chevron_right', color='gray').classes('text-gray-400')
|
|
234
|
+
|
|
235
|
+
# EDITOR HEADER
|
|
236
|
+
with ui.row().classes('w-full items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-slate-700'):
|
|
237
|
+
with ui.row().classes('items-center bg-slate-50 dark:bg-slate-900 gap-2'):
|
|
238
|
+
prop_search = ui.input('Search properties...').classes('w-64').props('dense rounded outlined')
|
|
239
|
+
|
|
240
|
+
def locate_in_tree(p=path):
|
|
241
|
+
if self.tree:
|
|
242
|
+
parts = p.split('/')
|
|
243
|
+
paths_to_expand = ['/'.join(parts[:i+1]) for i in range(len(parts))]
|
|
244
|
+
self.tree.expand(paths_to_expand)
|
|
245
|
+
ui.notify('Located in tree', color='blue')
|
|
246
|
+
|
|
247
|
+
ui.button(icon='my_location', on_click=locate_in_tree).props('flat round').classes('text-slate-600 dark:text-slate-400').tooltip('Locate in Tree')
|
|
248
|
+
|
|
249
|
+
with ui.button(icon='add').props('flat round').classes('text-blue-500').tooltip('Add Property / Node'):
|
|
250
|
+
with ui.menu() as add_menu:
|
|
251
|
+
opts = self.get_allowed_options(path, data_node)
|
|
252
|
+
if not opts:
|
|
253
|
+
ui.menu_item('No actions available').classes('text-gray-400')
|
|
254
|
+
for opt in opts:
|
|
255
|
+
def make_add_callback(o=opt):
|
|
256
|
+
return lambda: self.handle_add_node(path, o)
|
|
257
|
+
ui.menu_item(opt['label'], auto_close=True, on_click=make_add_callback()).classes('flex items-center gap-2')
|
|
258
|
+
|
|
259
|
+
if path != "root":
|
|
260
|
+
def delete_current_container():
|
|
261
|
+
parent_path = "/".join(path.split('/')[:-1])
|
|
262
|
+
node_key = path.split('/')[-1]
|
|
263
|
+
parent_node = self.state.get_data_by_path(parent_path)
|
|
264
|
+
if isinstance(parent_node, dict): parent_node.pop(node_key, None)
|
|
265
|
+
elif isinstance(parent_node, list): parent_node.pop(int(node_key))
|
|
266
|
+
self.state.commit()
|
|
267
|
+
self.selected_path["value"] = parent_path
|
|
268
|
+
self.refresh_tree_and_editor()
|
|
269
|
+
|
|
270
|
+
ui.button(icon='delete', on_click=delete_current_container).props('flat round').classes('text-red-500').tooltip('Delete this entire container')
|
|
271
|
+
|
|
272
|
+
# PRIMITIVES EDITOR
|
|
273
|
+
props_container = ui.column().classes('w-full gap-4')
|
|
274
|
+
|
|
275
|
+
def render_primitive_input(k, v, parent_node):
|
|
276
|
+
def make_on_change(prop_key=k):
|
|
277
|
+
def handler(e):
|
|
278
|
+
self.state.set_data_by_path(self.selected_path["value"], str(prop_key), e.value)
|
|
279
|
+
self.state.commit()
|
|
280
|
+
self.update_save_btn_state()
|
|
281
|
+
return handler
|
|
282
|
+
|
|
283
|
+
meta = self.schema_manager.get_meta(str(k))
|
|
284
|
+
is_required = meta.get('required', False)
|
|
285
|
+
label_text = f"{k} *" if is_required else str(k)
|
|
286
|
+
options = meta.get('options')
|
|
287
|
+
|
|
288
|
+
with ui.row().classes('items-center flex-grow flex-nowrap gap-2 w-full'):
|
|
289
|
+
if options:
|
|
290
|
+
safe_options = list(options)
|
|
291
|
+
if v not in safe_options: safe_options.append(v)
|
|
292
|
+
inp = ui.select(safe_options, value=v, label=label_text).classes('flex-grow').on_value_change(make_on_change())
|
|
293
|
+
elif isinstance(v, bool):
|
|
294
|
+
inp = ui.switch(text=label_text, value=v).on_value_change(make_on_change())
|
|
295
|
+
elif isinstance(v, (int, float)):
|
|
296
|
+
inp = ui.number(label=label_text, value=v).classes('flex-grow').on_value_change(make_on_change())
|
|
297
|
+
else:
|
|
298
|
+
inp = ui.input(label=label_text, value=str(v)).classes('flex-grow').on_value_change(make_on_change())
|
|
299
|
+
|
|
300
|
+
inp.on('focus', lambda _: self.update_footer(str(k)))
|
|
301
|
+
|
|
302
|
+
if not is_required:
|
|
303
|
+
def delete_prop(pk=k, pd=parent_node):
|
|
304
|
+
if isinstance(pd, dict) and pk in pd: pd.pop(pk, None)
|
|
305
|
+
elif isinstance(pd, list) and int(pk) < len(pd): pd.pop(int(pk))
|
|
306
|
+
self.state.commit()
|
|
307
|
+
self.refresh_tree_and_editor()
|
|
308
|
+
ui.button(icon='delete_outline', color='red-400', on_click=delete_prop).props('flat round size=sm').tooltip('Remove Property').classes('mt-2')
|
|
309
|
+
|
|
310
|
+
with props_container:
|
|
311
|
+
has_primitives = False
|
|
312
|
+
if isinstance(data_node, dict):
|
|
313
|
+
for k, v in data_node.items():
|
|
314
|
+
if not isinstance(v, (dict, list)):
|
|
315
|
+
has_primitives = True
|
|
316
|
+
with ui.row().classes('w-full items-center gap-2'):
|
|
317
|
+
is_req = self.schema_manager.get_meta(str(k)).get('required', False)
|
|
318
|
+
ui.icon('lock', size='sm').classes('text-gray-400 w-8') if is_req else ui.icon('edit', size='sm').classes('text-blue-400 w-8')
|
|
319
|
+
render_primitive_input(k, v, data_node)
|
|
320
|
+
elif isinstance(data_node, list):
|
|
321
|
+
for i, v in enumerate(data_node):
|
|
322
|
+
if not isinstance(v, (dict, list)):
|
|
323
|
+
has_primitives = True
|
|
324
|
+
with ui.row().classes('w-full items-center gap-2'):
|
|
325
|
+
ui.icon('edit', size='sm').classes('text-blue-400 w-8')
|
|
326
|
+
render_primitive_input(i, v, data_node)
|
|
327
|
+
|
|
328
|
+
if not has_primitives:
|
|
329
|
+
ui.label("This node contains no primitive properties directly.").classes('text-gray-400 italic mt-2')
|
|
330
|
+
|
|
331
|
+
# SUB-CONTAINERS FOLDERS
|
|
332
|
+
sub_containers = []
|
|
333
|
+
if isinstance(data_node, dict):
|
|
334
|
+
for k, v in data_node.items():
|
|
335
|
+
if isinstance(v, dict) or (isinstance(v, list) and not (isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v))):
|
|
336
|
+
sub_containers.append((k, f"{path}/{k}"))
|
|
337
|
+
elif isinstance(data_node, list):
|
|
338
|
+
for i, v in enumerate(data_node):
|
|
339
|
+
if isinstance(v, (dict, list)):
|
|
340
|
+
item_label = self.schema_manager.get_item_label(v, f"{path}/{i}", self.state.config_data, f"Item [{i}]")
|
|
341
|
+
sub_containers.append((item_label, f"{path}/{i}"))
|
|
342
|
+
|
|
343
|
+
if sub_containers:
|
|
344
|
+
ui.separator().classes('my-6')
|
|
345
|
+
ui.label("Sub-Containers").classes('text-xl font-bold text-slate-800 dark:text-slate-200 mb-4')
|
|
346
|
+
with ui.row().classes('w-full gap-4 flex-wrap'):
|
|
347
|
+
for label, child_path in sub_containers:
|
|
348
|
+
with ui.card().tight().classes('cursor-pointer hover:bg-blue-50 border border-gray-200 dark:bg-slate-800 shadow-sm w-48').on('click', lambda _, cp=child_path: (self.selected_path.update({"value": cp}), self.refresh_tree_and_editor())):
|
|
349
|
+
with ui.row().classes('items-center p-3 gap-3 w-full'):
|
|
350
|
+
ui.icon('folder', color='primary', size='sm')
|
|
351
|
+
ui.label(str(label)).classes('font-bold text-slate-700 dark:text-slate-300 truncate w-32')
|
|
352
|
+
|
|
353
|
+
def render(self):
|
|
354
|
+
self.dark_mode = ui.dark_mode()
|
|
355
|
+
ui.add_head_html('''
|
|
356
|
+
<style>
|
|
357
|
+
body.body--light { background-color: #f8fafc; }
|
|
358
|
+
body.body--dark { background-color: #0f172a; }
|
|
359
|
+
.q-tree__node-header { width: 100%; }
|
|
360
|
+
|
|
361
|
+
body.body--dark .q-tree { color: #e2e8f0 !important; }
|
|
362
|
+
body.body--dark .q-field__native, body.body--dark .q-field__label { color: #e2e8f0 !important; }
|
|
363
|
+
body.body--dark .q-card { background-color: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; }
|
|
364
|
+
body.body--dark .bg-slate-50 { background-color: #0f172a !important; border-color: #334155 !important; }
|
|
365
|
+
</style>
|
|
366
|
+
''')
|
|
367
|
+
|
|
368
|
+
with ui.header().classes('bg-slate-800 text-white shadow-md p-4 flex justify-between items-center'):
|
|
369
|
+
with ui.row().classes('items-center gap-3 w-1/2 overflow-hidden'):
|
|
370
|
+
ui.icon('settings_input_component', size='md')
|
|
371
|
+
ui.label('StructUI Editor').classes('text-xl font-bold tracking-wide')
|
|
372
|
+
ui.badge().classes('text-xs ml-4 py-1 px-2 font-mono truncate max-w-sm').bind_text_from(self.state, 'data_dir', backward=lambda d: f"Workspace: {d}" if d else "No Workspace Loaded")
|
|
373
|
+
|
|
374
|
+
with ui.row().classes('gap-2 items-center'):
|
|
375
|
+
ui.button(icon='dark_mode', on_click=lambda: self.dark_mode.set_value(not self.dark_mode.value)).props('flat round color=white')
|
|
376
|
+
ui.separator().props('vertical color=gray-500').classes('mx-2')
|
|
377
|
+
|
|
378
|
+
# Load Configuration / Schema Buttons
|
|
379
|
+
async def pick_config_dir():
|
|
380
|
+
result = await LocalFilePicker(directory=self.state.data_dir, dirs_only=True, upper_limit=None, show_hidden_files=True)
|
|
381
|
+
if result:
|
|
382
|
+
self.state.data_dir = result[0]
|
|
383
|
+
self.state.load_files()
|
|
384
|
+
self.selected_path["value"] = "root"
|
|
385
|
+
self.refresh_tree_and_editor()
|
|
386
|
+
ui.notify(f'Loaded Configs from {self.state.data_dir}', color='blue')
|
|
387
|
+
|
|
388
|
+
async def pick_schema_file():
|
|
389
|
+
result = await LocalFilePicker(directory=os.path.dirname(os.path.abspath(self.schema_manager.schema_filepath)), multiple=False, upper_limit=None, show_hidden_files=True)
|
|
390
|
+
if result:
|
|
391
|
+
self.schema_manager.schema_filepath = result[0]
|
|
392
|
+
self.schema_manager._load_schema()
|
|
393
|
+
self.refresh_tree_and_editor()
|
|
394
|
+
ui.notify(f'Loaded Schema from {os.path.basename(self.schema_manager.schema_filepath)}', color='blue')
|
|
395
|
+
|
|
396
|
+
ui.button('Load Configs', icon='folder_open', color='slate-600', on_click=pick_config_dir).props('outline').tooltip("Select configuration directory to load")
|
|
397
|
+
ui.button('Load Schema', icon='schema', color='slate-600', on_click=pick_schema_file).props('outline').tooltip("Select schema file to load")
|
|
398
|
+
|
|
399
|
+
ui.separator().props('vertical color=gray-500').classes('mx-2')
|
|
400
|
+
|
|
401
|
+
# Exit Dialog
|
|
402
|
+
with ui.dialog() as exit_dialog, ui.card():
|
|
403
|
+
ui.label('Are you sure you want to exit?').classes('text-lg font-bold mb-4')
|
|
404
|
+
with ui.row().classes('w-full justify-between mt-2'):
|
|
405
|
+
ui.button('Cancel', color='gray', on_click=exit_dialog.close).props('outline')
|
|
406
|
+
with ui.row().classes('gap-2'):
|
|
407
|
+
ui.button('Exit without Saving', color='red', on_click=lambda: (ui.notify('Exiting...', type='warning'), ui.run_javascript('window.close()'), app.shutdown())).props('outline')
|
|
408
|
+
ui.button('Save and Exit', color='green', on_click=lambda: (self.state.save_all_to_disk(), ui.notify('Saved! Exiting...', color='green'), ui.run_javascript('window.close()'), app.shutdown()))
|
|
409
|
+
|
|
410
|
+
ui.button(icon='undo', color='slate-600', on_click=lambda: self.refresh_tree_and_editor() if self.state.undo() else ui.notify('Nothing to undo', type='warning')).props('flat round')
|
|
411
|
+
ui.button(icon='redo', color='slate-600', on_click=lambda: self.refresh_tree_and_editor() if self.state.redo() else ui.notify('Nothing to redo', type='warning')).props('flat round')
|
|
412
|
+
ui.separator().props('vertical')
|
|
413
|
+
self.save_btn = ui.button(icon='save', color='primary', on_click=lambda: (self.state.save_all_to_disk(), ui.notify('Saved Configurations', color='green'), self.refresh_tree_and_editor())).props('round')
|
|
414
|
+
self.save_btn.tooltip('Save Configurations')
|
|
415
|
+
ui.button(icon='power_settings_new', color='red-500', on_click=exit_dialog.open).props('flat round').tooltip('Exit Application')
|
|
416
|
+
|
|
417
|
+
with ui.row().classes('w-full h-[calc(100vh-80px)] overflow-hidden p-4 gap-4 flex-nowrap'):
|
|
418
|
+
with ui.column().classes('w-1/3 min-w-[300px] h-full gap-4'):
|
|
419
|
+
with ui.card().classes('w-full h-full p-0 shadow-md border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col'):
|
|
420
|
+
with ui.row().classes('w-full p-4 border-b border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 justify-between items-center'):
|
|
421
|
+
ui.label("Configuration Tree").classes('font-bold text-slate-700 dark:text-slate-300 tracking-wide uppercase text-sm')
|
|
422
|
+
with ui.row().classes('gap-1 items-center'):
|
|
423
|
+
tree_search = ui.input('Search...').classes('w-48').props('dense rounded outlined clearable')
|
|
424
|
+
ui.button(icon='unfold_less', on_click=lambda: self.tree.collapse() if self.tree else None).props('flat round size=sm').classes('text-slate-500').tooltip('Collapse All')
|
|
425
|
+
ui.button(icon='unfold_more', on_click=lambda: self.tree.expand() if self.tree else None).props('flat round size=sm').classes('text-slate-500').tooltip('Expand All')
|
|
426
|
+
|
|
427
|
+
with ui.scroll_area().classes('w-full bg-slate-50 dark:bg-slate-900 flex-grow p-4 pt-2'):
|
|
428
|
+
self.tree = ui.tree([self.build_tree_nodes(self.state.config_data)], label_key='label', on_select=lambda e: (self.selected_path.update({"value": e.value}), self.refresh_tree_and_editor()) if e.value else None).classes('w-full custom-tree font-medium text-slate-700 dark:text-slate-300')
|
|
429
|
+
if self.tree:
|
|
430
|
+
self.tree._props['selected'] = self.selected_path["value"]
|
|
431
|
+
self.tree.props('control-color=primary node-key=id')
|
|
432
|
+
tree_search.on_value_change(lambda e: (self.tree._props.update({'filter': e.value}), self.tree.update()) if self.tree else None)
|
|
433
|
+
self.tree.expand()
|
|
434
|
+
|
|
435
|
+
def handle_expanded(e):
|
|
436
|
+
if getattr(e, 'args', None) is not None:
|
|
437
|
+
old_expanded = set(self.tree._props.get('expanded', []))
|
|
438
|
+
new_expanded = set(e.args)
|
|
439
|
+
added = new_expanded - old_expanded
|
|
440
|
+
if added:
|
|
441
|
+
target = list(added)[0]
|
|
442
|
+
self.selected_path["value"] = target
|
|
443
|
+
self.refresh_tree_and_editor()
|
|
444
|
+
else:
|
|
445
|
+
self.tree._props['expanded'] = list(new_expanded)
|
|
446
|
+
self.tree.update()
|
|
447
|
+
|
|
448
|
+
self.tree.on('update:expanded', handle_expanded)
|
|
449
|
+
|
|
450
|
+
with ui.column().classes('w-2/3 flex-grow bg-slate-50 dark:bg-slate-900 h-full gap-4'):
|
|
451
|
+
with ui.card().classes('w-full h-3/4 shadow-md border border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 flex flex-col p-6 pt-4'):
|
|
452
|
+
ui.label("Properties Editor").classes('font-bold text-slate-700 dark:text-slate-300 tracking-wide uppercase text-sm mb-2')
|
|
453
|
+
self.editor_scroll_area = ui.scroll_area().classes('w-full flex-grow')
|
|
454
|
+
|
|
455
|
+
with ui.card().classes('w-full h-1/4 shadow-md border border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 p-4'):
|
|
456
|
+
self.footer_pane = ui.column().classes('w-full')
|
|
457
|
+
|
|
458
|
+
self.refresh_tree_and_editor()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A format-agnostic, schema-driven, hierarchical configuration UI.
|
|
5
|
+
Author: structui contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Web Environment
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: nicegui>=1.4.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
20
|
+
Requires-Dist: pywin32>=300; sys_platform == "win32"
|
|
21
|
+
|
|
22
|
+
# StructUI
|
|
23
|
+
|
|
24
|
+
StructUI is a format-agnostic, schema-driven, hierarchical configuration UI engine built in Python. Designed as a flexible architectural backbone, it parses standard configuration files (YAML, JSON, CSV, XML) and dynamically generates a live web-based property editor based on constraints and metadata defined in a schema file.
|
|
25
|
+
|
|
26
|
+
The architecture is explicitly decoupled, making it readily extensible to strict domain-specific specifications (e.g., AUTOSAR configurators) and agent-driven programmatic workflows.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Pillar A: Format-Agnostic Parsers:** cleanly separate UI generation from underlying data formats. Out-of-the-box support for YAML and JSON, with abstract base classes easily extensible to XML or CSV.
|
|
31
|
+
- **Pillar B: Hierarchical UI:** Dynamic tree-based rendering with full support for multidimensional containers, dynamic polymorphic list additions, and node mapping. Powered natively by NiceGUI.
|
|
32
|
+
- **Pillar C: Data Validity:** Enforces schema metadata strictly at the UI layer. Missing fields gracefully populate via defaults, required flags trigger locking, and nested typings are continuously evaluated.
|
|
33
|
+
- **Pillar D: Extensibility & Programmatic Control:** Decomposed core logic (App, Parser, State, Schema, UI) allowing external tools and wrappers (e.g. CLI, Agent Workflows) to invoke the editor or inject properties safely.
|
|
34
|
+
|
|
35
|
+
## Installation & Setup
|
|
36
|
+
|
|
37
|
+
StructUI leverages standard Python packaging mechanisms.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Clone the repository
|
|
41
|
+
git clone https://github.com/your-username/structui.git
|
|
42
|
+
cd structui
|
|
43
|
+
|
|
44
|
+
# Install the application locally
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Launch the editor in the current directory against your local configuration files by simply typing:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
structui --dir . --schema .structui_schema.yaml --port 8080
|
|
54
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
structui/__init__.py,sha256=X9solSS5AR_nnc7a5b6WzTEH2-YV1AID4uH_mI6FeIw,161
|
|
2
|
+
structui/app.py,sha256=u8tkJFG2QHhBoFK2YdV2fBX9SlkhOF6h9qdYLOG2Ocw,558
|
|
3
|
+
structui/cli.py,sha256=IQT6-i1Pd-d7-M-FFR36C3KEa9z9v8yVxpQUZbQCk4k,781
|
|
4
|
+
structui/file_picker.py,sha256=fN5csaPKgMqf-bmrejQEPaH9awZVKDjDfHOPUj53AFM,3719
|
|
5
|
+
structui/parser.py,sha256=APGoUBUM2gczwZnF7fMiHI1LbxTeNndIS7ya50idGjE,1607
|
|
6
|
+
structui/schema.py,sha256=sAjJnYqblIzy8BhVjG-aRo52p2hXWELUxdz0ZjIxTg0,5403
|
|
7
|
+
structui/state.py,sha256=cz1AUNodTXnQvqJnJbxXPGrrzoIvqSk1C-Hjcht9AKY,5474
|
|
8
|
+
structui/ui.py,sha256=htO0YZmba0S2Q-nJ7hpRHd8g4H4lFvA9P6pFo0EWrxc,28751
|
|
9
|
+
structui-0.1.0.dist-info/METADATA,sha256=dhya0ciM-54qux3UnJXWaZpqy58h8aSYLRnnUza9ZXg,2633
|
|
10
|
+
structui-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
11
|
+
structui-0.1.0.dist-info/entry_points.txt,sha256=UdaDGP9j50oP075Ak_LoXYPCGqsv5LZhuuh0ot-1qLk,47
|
|
12
|
+
structui-0.1.0.dist-info/top_level.txt,sha256=1EXWIhwYOrJkPjryNqLSFl1_beNghV48nZzEqQ1YEGg,9
|
|
13
|
+
structui-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
structui
|