configui 1.0.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.
- configui/__about__.py +1 -0
- configui/__init__.py +3 -0
- configui/__main__.py +4 -0
- configui/config/__init__.py +7 -0
- configui/config/_atomic.py +49 -0
- configui/config/_protocol.py +18 -0
- configui/config/formats.py +37 -0
- configui/config/json_config.py +37 -0
- configui/config/toml_config.py +39 -0
- configui/config/yaml_config.py +47 -0
- configui/tui/__init__.py +0 -0
- configui/tui/app.py +347 -0
- configui/tui/cli.py +14 -0
- configui/tui/collector.py +70 -0
- configui/tui/screens.py +178 -0
- configui/tui/widgets.py +108 -0
- configui-1.0.0.dist-info/METADATA +82 -0
- configui-1.0.0.dist-info/RECORD +21 -0
- configui-1.0.0.dist-info/WHEEL +4 -0
- configui-1.0.0.dist-info/entry_points.txt +2 -0
- configui-1.0.0.dist-info/licenses/LICENSE +21 -0
configui/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
configui/__init__.py
ADDED
configui/__main__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from configui.config._protocol import Config
|
|
2
|
+
from configui.config.formats import SupportedConfigFormat
|
|
3
|
+
from configui.config.json_config import JsonConfig
|
|
4
|
+
from configui.config.toml_config import TomlConfig
|
|
5
|
+
from configui.config.yaml_config import YamlConfig
|
|
6
|
+
|
|
7
|
+
__all__ = ["Config", "JsonConfig", "SupportedConfigFormat", "TomlConfig", "YamlConfig"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable, MutableMapping
|
|
11
|
+
|
|
12
|
+
_ALLOW_NEW_KEYS: bool = os.environ.get("CONFIGUI_UPDATE_ALLOW_NEW_KEYS", "0") == "1"
|
|
13
|
+
_ALLOW_MISSING_KEYS: bool = os.environ.get("CONFIGUI_UPDATE_ALLOW_MISSING_KEYS", "1") == "1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def atomic_update(
|
|
17
|
+
data: MutableMapping,
|
|
18
|
+
updates: dict[str, Any],
|
|
19
|
+
*,
|
|
20
|
+
allow_new_keys: bool,
|
|
21
|
+
allow_missing_keys: bool,
|
|
22
|
+
) -> None:
|
|
23
|
+
if not allow_new_keys:
|
|
24
|
+
extra = updates.keys() - data.keys()
|
|
25
|
+
if extra:
|
|
26
|
+
msg = f"Keys not found in config: {sorted(extra)}"
|
|
27
|
+
raise KeyError(msg)
|
|
28
|
+
if not allow_missing_keys:
|
|
29
|
+
missing = data.keys() - updates.keys()
|
|
30
|
+
if missing:
|
|
31
|
+
msg = f"Missing keys in update: {sorted(missing)}"
|
|
32
|
+
raise KeyError(msg)
|
|
33
|
+
for k, v in updates.items():
|
|
34
|
+
data[k] = v
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def atomic_write(path: Path, writer: Callable[[IO[str]], None]) -> None:
|
|
38
|
+
path = Path(path)
|
|
39
|
+
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=path.parent)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(fd, "w") as f:
|
|
42
|
+
writer(f)
|
|
43
|
+
os.replace(tmp_path, path)
|
|
44
|
+
except BaseException as e:
|
|
45
|
+
msg = f"Failed to save config to '{path}'"
|
|
46
|
+
raise RuntimeError(msg) from e
|
|
47
|
+
finally:
|
|
48
|
+
with suppress(BaseException):
|
|
49
|
+
os.unlink(tmp_path)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config(Protocol):
|
|
7
|
+
_data: Mapping
|
|
8
|
+
_path: Path
|
|
9
|
+
|
|
10
|
+
def load(self, **kwargs: Any) -> None: ...
|
|
11
|
+
|
|
12
|
+
def save(self, **kwargs: Any) -> None: ...
|
|
13
|
+
|
|
14
|
+
def save_as(self, new_path: Path, **kwargs: Any) -> None: ...
|
|
15
|
+
|
|
16
|
+
def update(
|
|
17
|
+
self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
|
|
18
|
+
) -> None: ...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum, auto
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from configui.config._protocol import Config
|
|
9
|
+
|
|
10
|
+
from configui.config.json_config import JsonConfig
|
|
11
|
+
from configui.config.toml_config import TomlConfig
|
|
12
|
+
from configui.config.yaml_config import YamlConfig
|
|
13
|
+
|
|
14
|
+
_YAML_ALIASES: set[str] = {"yml", "yaml"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupportedConfigFormat(StrEnum):
|
|
18
|
+
JSON = auto()
|
|
19
|
+
YAML = auto()
|
|
20
|
+
TOML = auto()
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_filename(cls, filename: str) -> SupportedConfigFormat:
|
|
24
|
+
ext = Path(filename).suffix.lstrip(".").lower()
|
|
25
|
+
if ext in _YAML_ALIASES:
|
|
26
|
+
ext = "yaml"
|
|
27
|
+
return cls(ext)
|
|
28
|
+
|
|
29
|
+
def get_config_cls(self) -> type[Config]:
|
|
30
|
+
return _CONFIG_CLASSES[self]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_CONFIG_CLASSES: dict[SupportedConfigFormat, type[Config]] = {
|
|
34
|
+
SupportedConfigFormat.JSON: JsonConfig,
|
|
35
|
+
SupportedConfigFormat.YAML: YamlConfig,
|
|
36
|
+
SupportedConfigFormat.TOML: TomlConfig,
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
|
|
8
|
+
from configui.config._protocol import Config
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import MutableMapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JsonConfig(Config):
|
|
15
|
+
def __init__(self, path: str | Path) -> None:
|
|
16
|
+
self._path = Path(path)
|
|
17
|
+
self._data: MutableMapping[str, Any] = {}
|
|
18
|
+
|
|
19
|
+
def load(self, **kwargs: Any) -> None:
|
|
20
|
+
try:
|
|
21
|
+
self._data = json.loads(self._path.read_text(**kwargs))
|
|
22
|
+
except json.JSONDecodeError as e:
|
|
23
|
+
msg = f"Failed to parse JSON file '{self._path}': {e}"
|
|
24
|
+
raise ValueError(msg) from e
|
|
25
|
+
|
|
26
|
+
def save(self, **kwargs: Any) -> None:
|
|
27
|
+
atomic_write(self._path, lambda f: json.dump(self._data, f, indent=2, ensure_ascii=False, **kwargs))
|
|
28
|
+
|
|
29
|
+
def save_as(self, new_path: Path, **kwargs: Any) -> None:
|
|
30
|
+
atomic_write(new_path, lambda f: json.dump(self._data, f, indent=2, ensure_ascii=False, **kwargs))
|
|
31
|
+
|
|
32
|
+
def update(
|
|
33
|
+
self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
|
|
34
|
+
) -> None:
|
|
35
|
+
_allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
|
|
36
|
+
_allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
|
|
37
|
+
atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import tomlkit
|
|
7
|
+
from tomlkit.exceptions import TOMLKitError
|
|
8
|
+
|
|
9
|
+
from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
|
|
10
|
+
from configui.config._protocol import Config
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import MutableMapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TomlConfig(Config):
|
|
17
|
+
def __init__(self, path: str | Path) -> None:
|
|
18
|
+
self._path = Path(path)
|
|
19
|
+
self._data: MutableMapping[str, Any] = {}
|
|
20
|
+
|
|
21
|
+
def load(self, **kwargs: Any) -> None:
|
|
22
|
+
try:
|
|
23
|
+
self._data = tomlkit.parse(self._path.read_text(**kwargs))
|
|
24
|
+
except TOMLKitError as e:
|
|
25
|
+
msg = f"Failed to parse TOML file '{self._path}': {e}"
|
|
26
|
+
raise ValueError(msg) from e
|
|
27
|
+
|
|
28
|
+
def save(self, **_kwargs: Any) -> None:
|
|
29
|
+
atomic_write(self._path, lambda f: tomlkit.dump(self._data, f))
|
|
30
|
+
|
|
31
|
+
def save_as(self, new_path: Path, **_kwargs: Any) -> None:
|
|
32
|
+
atomic_write(new_path, lambda f: tomlkit.dump(self._data, f))
|
|
33
|
+
|
|
34
|
+
def update(
|
|
35
|
+
self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
|
|
36
|
+
) -> None:
|
|
37
|
+
_allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
|
|
38
|
+
_allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
|
|
39
|
+
atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from ruamel.yaml import YAML
|
|
7
|
+
from ruamel.yaml.error import YAMLError
|
|
8
|
+
|
|
9
|
+
from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
|
|
10
|
+
from configui.config._protocol import Config
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import MutableMapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class YamlConfig(Config):
|
|
17
|
+
def __init__(self, path: str | Path, yaml_type: str = "rt") -> None:
|
|
18
|
+
"""Initialize YamlConfig.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Path to the YAML file.
|
|
22
|
+
yaml_type: ruamel.yaml round-trip mode — ``"rt"`` preserves
|
|
23
|
+
comments, anchors, aliases, and key ordering on load/edit/save.
|
|
24
|
+
"""
|
|
25
|
+
self._path = Path(path)
|
|
26
|
+
self._data: MutableMapping[str, Any] = {}
|
|
27
|
+
self._yaml = YAML(typ=yaml_type)
|
|
28
|
+
|
|
29
|
+
def load(self, **kwargs: Any) -> None:
|
|
30
|
+
try:
|
|
31
|
+
self._data = self._yaml.load(self._path.read_text(**kwargs))
|
|
32
|
+
except YAMLError as e:
|
|
33
|
+
msg = f"Failed to parse YAML file '{self._path}': {e}"
|
|
34
|
+
raise ValueError(msg) from e
|
|
35
|
+
|
|
36
|
+
def save(self, **_kwargs: Any) -> None:
|
|
37
|
+
atomic_write(self._path, lambda f: self._yaml.dump(self._data, f))
|
|
38
|
+
|
|
39
|
+
def save_as(self, new_path: Path, **_kwargs: Any) -> None:
|
|
40
|
+
atomic_write(new_path, lambda f: self._yaml.dump(self._data, f))
|
|
41
|
+
|
|
42
|
+
def update(
|
|
43
|
+
self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
_allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
|
|
46
|
+
_allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
|
|
47
|
+
atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
|
configui/tui/__init__.py
ADDED
|
File without changes
|
configui/tui/app.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.widgets import Button, Collapsible, Footer, Header, Input, Switch, Tree
|
|
12
|
+
|
|
13
|
+
from configui.config import SupportedConfigFormat
|
|
14
|
+
from configui.tui.collector import map_widgets_to_config
|
|
15
|
+
from configui.tui.screens import ConfirmExitScreen, ResetConfirmScreen, SaveAsScreen
|
|
16
|
+
from configui.tui.widgets import map_config_to_widgets
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from textual.widgets.tree import TreeNode
|
|
20
|
+
|
|
21
|
+
_MOUNT_SETTLE_YIELDS: int = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigUIApp(App[None]):
|
|
25
|
+
TITLE = "ConfigUI"
|
|
26
|
+
|
|
27
|
+
CSS = """
|
|
28
|
+
Screen {
|
|
29
|
+
layout: grid;
|
|
30
|
+
grid-size: 1 3;
|
|
31
|
+
grid-rows: auto 1fr auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#main-container {
|
|
35
|
+
layout: horizontal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#sidebar-tree {
|
|
39
|
+
dock: left;
|
|
40
|
+
width: 30%;
|
|
41
|
+
min-width: 30;
|
|
42
|
+
max-width: 50;
|
|
43
|
+
border: round $primary;
|
|
44
|
+
overflow-y: auto;
|
|
45
|
+
margin: 0 0 0 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#sidebar-tree:focus-within {
|
|
49
|
+
border: round $primary;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#main-content {
|
|
53
|
+
width: 1fr;
|
|
54
|
+
height: 1fr;
|
|
55
|
+
border: round $primary;
|
|
56
|
+
overflow-y: auto;
|
|
57
|
+
padding: 1 2;
|
|
58
|
+
margin: 0 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#main-content:focus-within {
|
|
62
|
+
border: round $primary;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.scalar-row {
|
|
66
|
+
height: auto;
|
|
67
|
+
margin: 0 0 1 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.scalar-label {
|
|
71
|
+
width: auto;
|
|
72
|
+
height: 3;
|
|
73
|
+
padding: 0 1 0 0;
|
|
74
|
+
color: $text;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
content-align: left middle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.scalar-input {
|
|
80
|
+
width: 1fr;
|
|
81
|
+
height: 3;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Input.scalar-input {
|
|
85
|
+
background: $surface;
|
|
86
|
+
border: round $primary;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Input.scalar-input:focus {
|
|
90
|
+
background: $surface;
|
|
91
|
+
border: round $primary;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Switch.scalar-input {
|
|
95
|
+
width: auto;
|
|
96
|
+
height: 3;
|
|
97
|
+
border: round $primary;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Switch.scalar-input:focus {
|
|
101
|
+
border: round $primary;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.reset-btn {
|
|
105
|
+
width: auto;
|
|
106
|
+
height: 3;
|
|
107
|
+
min-width: 3;
|
|
108
|
+
margin: 0 0 0 1;
|
|
109
|
+
background: transparent;
|
|
110
|
+
border: none;
|
|
111
|
+
color: $text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.reset-btn:hover {
|
|
115
|
+
color: $primary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.content-wrapper {
|
|
119
|
+
height: auto;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
Collapsible > .container-dict,
|
|
123
|
+
Collapsible > .container-list {
|
|
124
|
+
margin: 0 0 0 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Tree {
|
|
128
|
+
padding: 0 1;
|
|
129
|
+
}
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
BINDINGS: ClassVar[list[Binding]] = [ # type: ignore[assignment]
|
|
133
|
+
Binding("ctrl+s", "save", "Save", priority=True),
|
|
134
|
+
Binding("ctrl+shift+s", "save_as", "Save As", priority=True),
|
|
135
|
+
Binding("ctrl+r", "reset_all", "Reset All", priority=True),
|
|
136
|
+
Binding("ctrl+q", "quit", "Quit", priority=True),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def __init__(self, path: str, *, read_only: bool = False) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self._filepath = Path(path)
|
|
142
|
+
self._read_only = read_only
|
|
143
|
+
self._dirty = False
|
|
144
|
+
self._config = None
|
|
145
|
+
self._original_data: dict[str, Any] = {}
|
|
146
|
+
self._tree_nodes: dict[str, TreeNode] = {}
|
|
147
|
+
self._header: Header | None = None
|
|
148
|
+
self._mount_phase = True
|
|
149
|
+
|
|
150
|
+
def _load_config(self):
|
|
151
|
+
fmt = SupportedConfigFormat.from_filename(self._filepath.name)
|
|
152
|
+
config = fmt.get_config_cls()(self._filepath)
|
|
153
|
+
config.load()
|
|
154
|
+
return config
|
|
155
|
+
|
|
156
|
+
def compose(self) -> ComposeResult:
|
|
157
|
+
yield Header(show_clock=False)
|
|
158
|
+
with Horizontal(id="main-container"):
|
|
159
|
+
yield Tree("config", id="sidebar-tree")
|
|
160
|
+
yield Vertical(id="main-content")
|
|
161
|
+
yield Footer()
|
|
162
|
+
|
|
163
|
+
async def on_mount(self) -> None:
|
|
164
|
+
self._config = self._load_config()
|
|
165
|
+
data: dict[str, Any] = cast("dict[str, Any]", self._config._data) # type: ignore[attr-defined,union-attr] # noqa: SLF001
|
|
166
|
+
self._original_data = deepcopy(data)
|
|
167
|
+
|
|
168
|
+
self._header = self.query_one(Header)
|
|
169
|
+
content = self.query_one("#main-content", Vertical)
|
|
170
|
+
content.remove_children()
|
|
171
|
+
content.mount(map_config_to_widgets(data))
|
|
172
|
+
|
|
173
|
+
tree = self.query_one("#sidebar-tree", Tree)
|
|
174
|
+
tree.clear()
|
|
175
|
+
self._tree_nodes.clear()
|
|
176
|
+
self._populate_tree(tree.root, data)
|
|
177
|
+
|
|
178
|
+
if self._read_only:
|
|
179
|
+
self._header.sub_title = "[READ ONLY]" # type: ignore[union-attr,attr-defined]
|
|
180
|
+
else:
|
|
181
|
+
self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
|
|
182
|
+
|
|
183
|
+
for _ in range(_MOUNT_SETTLE_YIELDS):
|
|
184
|
+
await asyncio.sleep(0)
|
|
185
|
+
|
|
186
|
+
self.call_later(self._end_mount_phase)
|
|
187
|
+
|
|
188
|
+
def _end_mount_phase(self) -> None:
|
|
189
|
+
self._mount_phase = False
|
|
190
|
+
self._dirty = False
|
|
191
|
+
if self._read_only:
|
|
192
|
+
self._header.sub_title = "[READ ONLY]" # type: ignore[union-attr,attr-defined]
|
|
193
|
+
else:
|
|
194
|
+
self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
|
|
195
|
+
|
|
196
|
+
def _get_nested(self, data: dict[str, Any], dotted_path: str) -> Any:
|
|
197
|
+
parts = dotted_path.split(".")
|
|
198
|
+
current: Any = data
|
|
199
|
+
for part in parts:
|
|
200
|
+
current = current[int(part)] if isinstance(current, list) and part.isdigit() else current[part] # type: ignore[index]
|
|
201
|
+
return current
|
|
202
|
+
|
|
203
|
+
def _populate_tree(self, node: TreeNode, data: dict[str, Any], prefix: str = "") -> None:
|
|
204
|
+
for key, value in data.items():
|
|
205
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
206
|
+
if isinstance(value, dict):
|
|
207
|
+
child = node.add(key, expand=True)
|
|
208
|
+
child.data = {"path": path}
|
|
209
|
+
self._tree_nodes[path] = child
|
|
210
|
+
self._populate_tree(child, value, prefix=path)
|
|
211
|
+
else:
|
|
212
|
+
child = node.add_leaf(key)
|
|
213
|
+
child.data = {"path": path}
|
|
214
|
+
self._tree_nodes[path] = child
|
|
215
|
+
|
|
216
|
+
def _expand_tree_ancestors(self, key_path: str) -> None:
|
|
217
|
+
parts = key_path.split(".")
|
|
218
|
+
for i in range(1, len(parts)):
|
|
219
|
+
ancestor_path = ".".join(parts[:i])
|
|
220
|
+
ancestor = self._tree_nodes.get(ancestor_path)
|
|
221
|
+
if ancestor is not None:
|
|
222
|
+
ancestor.expand()
|
|
223
|
+
|
|
224
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
225
|
+
if event.node.tree.id != "sidebar-tree":
|
|
226
|
+
return
|
|
227
|
+
data = event.node.data
|
|
228
|
+
if data is None or "path" not in data:
|
|
229
|
+
return
|
|
230
|
+
dotted_path = data["path"]
|
|
231
|
+
wid = dotted_path.replace(".", "_")
|
|
232
|
+
try:
|
|
233
|
+
widget = self.query_one(f"#{wid}")
|
|
234
|
+
if isinstance(widget, Collapsible) and widget.collapsed:
|
|
235
|
+
widget.collapsed = False
|
|
236
|
+
widget.scroll_visible()
|
|
237
|
+
except Exception: # noqa: BLE001, S110
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def _sync_tree_from_collapsible(self, collapsible_id: str, *, expanded: bool) -> None:
|
|
241
|
+
key_path = collapsible_id.replace("_", ".")
|
|
242
|
+
node = self._tree_nodes.get(key_path)
|
|
243
|
+
if node is not None:
|
|
244
|
+
if expanded:
|
|
245
|
+
node.expand()
|
|
246
|
+
else:
|
|
247
|
+
node.collapse()
|
|
248
|
+
self._expand_tree_ancestors(key_path)
|
|
249
|
+
|
|
250
|
+
def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None:
|
|
251
|
+
wid = event.collapsible.id
|
|
252
|
+
if wid:
|
|
253
|
+
self._sync_tree_from_collapsible(wid, expanded=True)
|
|
254
|
+
|
|
255
|
+
def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None:
|
|
256
|
+
wid = event.collapsible.id
|
|
257
|
+
if wid:
|
|
258
|
+
self._sync_tree_from_collapsible(wid, expanded=False)
|
|
259
|
+
|
|
260
|
+
def _mark_dirty(self) -> None:
|
|
261
|
+
if self._mount_phase or self._read_only:
|
|
262
|
+
return
|
|
263
|
+
if not self._dirty:
|
|
264
|
+
self._dirty = True
|
|
265
|
+
self._header.sub_title = f"{self._filepath.name} *" # type: ignore[union-attr,attr-defined]
|
|
266
|
+
|
|
267
|
+
def on_input_changed(self, _event: Input.Changed) -> None:
|
|
268
|
+
self._mark_dirty()
|
|
269
|
+
|
|
270
|
+
def on_switch_changed(self, _event: Switch.Changed) -> None:
|
|
271
|
+
self._mark_dirty()
|
|
272
|
+
|
|
273
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
274
|
+
button_id = event.button.id
|
|
275
|
+
if button_id is None:
|
|
276
|
+
return
|
|
277
|
+
if button_id.startswith("reset-") and "reset-btn" in event.button.classes:
|
|
278
|
+
widget_id = button_id[6:]
|
|
279
|
+
widget = self.query_one(f"#{widget_id}")
|
|
280
|
+
row = widget.parent if isinstance(widget.parent, Horizontal) else None
|
|
281
|
+
dotted_path = getattr(row, "reset_path", None) or widget_id.replace("_", ".")
|
|
282
|
+
original_value = self._get_nested(self._original_data, dotted_path)
|
|
283
|
+
if isinstance(widget, Switch):
|
|
284
|
+
widget.value = bool(original_value)
|
|
285
|
+
elif isinstance(widget, Input):
|
|
286
|
+
widget.value = str(original_value)
|
|
287
|
+
self._mark_dirty()
|
|
288
|
+
|
|
289
|
+
def action_save(self) -> None:
|
|
290
|
+
if self._read_only or not self._dirty:
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
content = self.query_one("#main-content", Vertical)
|
|
294
|
+
data = map_widgets_to_config(content)
|
|
295
|
+
self._config.update(data) # type: ignore[attr-defined]
|
|
296
|
+
self._config.save() # type: ignore[attr-defined]
|
|
297
|
+
self._dirty = False
|
|
298
|
+
self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
|
|
299
|
+
except Exception as exc: # noqa: BLE001
|
|
300
|
+
self.notify(str(exc), severity="error", title="Save failed")
|
|
301
|
+
|
|
302
|
+
def action_save_as(self) -> None:
|
|
303
|
+
if self._read_only:
|
|
304
|
+
return
|
|
305
|
+
self.push_screen(SaveAsScreen(self._filepath), self._on_save_as_result)
|
|
306
|
+
|
|
307
|
+
def _on_save_as_result(self, new_path: Path | None) -> None:
|
|
308
|
+
if new_path is None:
|
|
309
|
+
return
|
|
310
|
+
try:
|
|
311
|
+
content = self.query_one("#main-content", Vertical)
|
|
312
|
+
data = map_widgets_to_config(content)
|
|
313
|
+
self._config.update(data) # type: ignore[attr-defined]
|
|
314
|
+
self._config.save_as(new_path) # type: ignore[attr-defined]
|
|
315
|
+
self._filepath = new_path
|
|
316
|
+
self._dirty = False
|
|
317
|
+
self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
self.notify(str(exc), severity="error", title="Save As failed")
|
|
320
|
+
|
|
321
|
+
def action_reset_all(self) -> None:
|
|
322
|
+
if self._read_only or not self._dirty:
|
|
323
|
+
return
|
|
324
|
+
self.push_screen(ResetConfirmScreen(), self._on_reset_all_result) # type: ignore[arg-type]
|
|
325
|
+
|
|
326
|
+
def _on_reset_all_result(self, confirmed: bool) -> None: # noqa: FBT001
|
|
327
|
+
if not confirmed:
|
|
328
|
+
return
|
|
329
|
+
content = self.query_one("#main-content", Vertical)
|
|
330
|
+
content.remove_children()
|
|
331
|
+
content.mount(map_config_to_widgets(self._original_data))
|
|
332
|
+
tree = self.query_one("#sidebar-tree", Tree)
|
|
333
|
+
tree.clear()
|
|
334
|
+
self._tree_nodes.clear()
|
|
335
|
+
self._populate_tree(tree.root, self._original_data)
|
|
336
|
+
self._dirty = False
|
|
337
|
+
self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
|
|
338
|
+
|
|
339
|
+
def action_quit(self) -> None: # type: ignore[override]
|
|
340
|
+
if self._dirty:
|
|
341
|
+
self.push_screen(ConfirmExitScreen(), self._on_quit_result) # type: ignore[arg-type]
|
|
342
|
+
else:
|
|
343
|
+
self.exit()
|
|
344
|
+
|
|
345
|
+
def _on_quit_result(self, confirmed: bool) -> None: # noqa: FBT001
|
|
346
|
+
if confirmed:
|
|
347
|
+
self.exit()
|
configui/tui/cli.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from configui.tui.app import ConfigUIApp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
10
|
+
@click.option("-r", "--read-only", is_flag=True, help="Open in read-only mode")
|
|
11
|
+
def main(path: str, read_only: bool) -> None: # noqa: FBT001
|
|
12
|
+
"""Open a configuration file in the ConfigUI TUI editor."""
|
|
13
|
+
app = ConfigUIApp(path, read_only=read_only)
|
|
14
|
+
app.run()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.widgets import Collapsible, Input, Static, Switch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _extract_value(widget: Switch | Input) -> Any:
|
|
10
|
+
if isinstance(widget, Switch):
|
|
11
|
+
return widget.value
|
|
12
|
+
if widget.type == "integer":
|
|
13
|
+
return int(widget.value) if widget.value else 0
|
|
14
|
+
if widget.type == "number":
|
|
15
|
+
return float(widget.value) if widget.value else 0.0
|
|
16
|
+
return widget.value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _content_children(coll: Collapsible):
|
|
20
|
+
"""Yield actual user-provided children of a Collapsible, skipping internal wrappers."""
|
|
21
|
+
contents = list(coll.children)[1]
|
|
22
|
+
yield from contents.children
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _collect(widget: Horizontal | Collapsible, container: dict[str, Any] | list[Any]) -> None:
|
|
26
|
+
if isinstance(widget, Horizontal):
|
|
27
|
+
label = cast("Static", widget.children[0])
|
|
28
|
+
value_widget = cast("Switch | Input", widget.children[1])
|
|
29
|
+
key = str(label.content)
|
|
30
|
+
value = _extract_value(value_widget)
|
|
31
|
+
if isinstance(container, dict):
|
|
32
|
+
container[key] = value
|
|
33
|
+
else:
|
|
34
|
+
container.append(value)
|
|
35
|
+
elif isinstance(widget, Collapsible):
|
|
36
|
+
sub: list[Any] | dict[str, Any]
|
|
37
|
+
if "container-dict" in widget.classes:
|
|
38
|
+
sub = {}
|
|
39
|
+
for child in _content_children(widget):
|
|
40
|
+
_collect(cast("Horizontal | Collapsible", child), sub)
|
|
41
|
+
elif "container-list" in widget.classes:
|
|
42
|
+
sub = []
|
|
43
|
+
for child in _content_children(widget):
|
|
44
|
+
_collect(cast("Horizontal | Collapsible", child), sub)
|
|
45
|
+
else:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if isinstance(container, dict):
|
|
49
|
+
key = str(widget.title)
|
|
50
|
+
if "container-list" in widget.classes:
|
|
51
|
+
key = key.rsplit(" [", 1)[0]
|
|
52
|
+
container[key] = sub
|
|
53
|
+
else:
|
|
54
|
+
container.append(sub)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _unwrap(root: Vertical) -> Vertical:
|
|
58
|
+
"""Unwrap a single wrapper Vertical, if present."""
|
|
59
|
+
children = list(root.children)
|
|
60
|
+
if len(children) == 1 and isinstance(children[0], Vertical):
|
|
61
|
+
return cast("Vertical", children[0])
|
|
62
|
+
return root
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def map_widgets_to_config(root: Vertical) -> dict[str, Any]:
|
|
66
|
+
result: dict[str, Any] = {}
|
|
67
|
+
target = _unwrap(root)
|
|
68
|
+
for child in target.children:
|
|
69
|
+
_collect(cast("Horizontal | Collapsible", child), result)
|
|
70
|
+
return result
|
configui/tui/screens.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import Button, Input, Label, Static
|
|
9
|
+
|
|
10
|
+
from configui.tui.widgets import NavigableDirectoryTree
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfirmExitScreen(Screen[bool]):
|
|
17
|
+
CSS = """
|
|
18
|
+
ConfirmExitScreen {
|
|
19
|
+
align: center middle;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#confirm-exit-box {
|
|
23
|
+
width: 50;
|
|
24
|
+
height: auto;
|
|
25
|
+
padding: 2;
|
|
26
|
+
border: round $primary;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#confirm-exit-box > Label {
|
|
30
|
+
text-align: center;
|
|
31
|
+
margin: 1 0 2 0;
|
|
32
|
+
}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
with Vertical(id="confirm-exit-box"):
|
|
37
|
+
yield Label("You have unsaved changes. Exit anyway?")
|
|
38
|
+
yield Button("Cancel", variant="primary", id="cancel")
|
|
39
|
+
yield Button("Exit", variant="error", id="exit")
|
|
40
|
+
|
|
41
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
42
|
+
if event.button.id == "exit":
|
|
43
|
+
self.dismiss(True)
|
|
44
|
+
else:
|
|
45
|
+
self.dismiss(False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ResetConfirmScreen(Screen[bool]):
|
|
49
|
+
CSS = """
|
|
50
|
+
ResetConfirmScreen {
|
|
51
|
+
align: center middle;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#reset-confirm-box {
|
|
55
|
+
width: 50;
|
|
56
|
+
height: auto;
|
|
57
|
+
padding: 2;
|
|
58
|
+
border: round $primary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#reset-confirm-box > Label {
|
|
62
|
+
text-align: center;
|
|
63
|
+
margin: 1 0 2 0;
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
with Vertical(id="reset-confirm-box"):
|
|
69
|
+
yield Label("Reset all fields to their original values?")
|
|
70
|
+
yield Button("Cancel", variant="primary", id="cancel")
|
|
71
|
+
yield Button("Reset All", variant="error", id="reset")
|
|
72
|
+
|
|
73
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
74
|
+
if event.button.id == "reset":
|
|
75
|
+
self.dismiss(True)
|
|
76
|
+
else:
|
|
77
|
+
self.dismiss(False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SaveAsScreen(Screen[Path | None]):
|
|
81
|
+
CSS = """
|
|
82
|
+
SaveAsScreen {
|
|
83
|
+
align: center middle;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#save-as-box {
|
|
87
|
+
width: 80;
|
|
88
|
+
height: auto;
|
|
89
|
+
padding: 2;
|
|
90
|
+
border: round $primary;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#save-as-box > Label {
|
|
94
|
+
text-align: center;
|
|
95
|
+
text-style: bold;
|
|
96
|
+
margin: 0 0 1 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#save-directory-tree {
|
|
100
|
+
height: 20;
|
|
101
|
+
width: 1fr;
|
|
102
|
+
border: round $surface;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#save-filename {
|
|
106
|
+
margin: 1 0;
|
|
107
|
+
border: round $primary;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#save-filename:focus {
|
|
111
|
+
border: round $primary;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#save-path-preview {
|
|
115
|
+
margin: 1 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#save-as-box > Button {
|
|
119
|
+
margin: 1 0 0 0;
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, current_path: Path) -> None:
|
|
124
|
+
super().__init__()
|
|
125
|
+
self._current_path = current_path
|
|
126
|
+
self._selected_dir: Path = current_path.parent
|
|
127
|
+
|
|
128
|
+
def compose(self) -> ComposeResult:
|
|
129
|
+
with Vertical(id="save-as-box"):
|
|
130
|
+
yield Label("Save As")
|
|
131
|
+
yield NavigableDirectoryTree(str(self._current_path.parent), id="save-directory-tree")
|
|
132
|
+
yield Input(value=self._current_path.name, id="save-filename", placeholder="Filename")
|
|
133
|
+
yield Static(id="save-path-preview")
|
|
134
|
+
yield Button("Browse", id="browse")
|
|
135
|
+
yield Button("Save", variant="primary", id="save")
|
|
136
|
+
yield Button("Cancel", id="cancel")
|
|
137
|
+
|
|
138
|
+
def on_mount(self) -> None:
|
|
139
|
+
self._update_preview()
|
|
140
|
+
|
|
141
|
+
def _get_save_path(self) -> Path | None:
|
|
142
|
+
filename = self.query_one("#save-filename", Input).value.strip()
|
|
143
|
+
if not filename:
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
candidate = Path(filename).expanduser()
|
|
147
|
+
except RuntimeError:
|
|
148
|
+
return None
|
|
149
|
+
if candidate.is_absolute():
|
|
150
|
+
return candidate
|
|
151
|
+
return self._selected_dir / candidate
|
|
152
|
+
|
|
153
|
+
def _update_preview(self) -> None:
|
|
154
|
+
save_path = self._get_save_path()
|
|
155
|
+
preview = self.query_one("#save-path-preview", Static)
|
|
156
|
+
if save_path:
|
|
157
|
+
preview.update(f"Save path: {save_path}")
|
|
158
|
+
else:
|
|
159
|
+
preview.update("")
|
|
160
|
+
|
|
161
|
+
def on_input_changed(self, _event: Input.Changed) -> None:
|
|
162
|
+
self._update_preview()
|
|
163
|
+
|
|
164
|
+
def on_directory_tree_directory_selected(self, event: NavigableDirectoryTree.DirectorySelected) -> None:
|
|
165
|
+
self._selected_dir = event.path
|
|
166
|
+
tree = self.query_one("#save-directory-tree", NavigableDirectoryTree)
|
|
167
|
+
tree.path = event.path
|
|
168
|
+
self._update_preview()
|
|
169
|
+
|
|
170
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
171
|
+
if event.button.id == "save":
|
|
172
|
+
save_path = self._get_save_path()
|
|
173
|
+
if save_path:
|
|
174
|
+
self.dismiss(save_path)
|
|
175
|
+
elif event.button.id == "cancel":
|
|
176
|
+
self.dismiss(None)
|
|
177
|
+
elif event.button.id == "browse":
|
|
178
|
+
self.query_one("#save-directory-tree", NavigableDirectoryTree).focus()
|
configui/tui/widgets.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.widgets import Button, Collapsible, DirectoryTree, Input, Static, Switch
|
|
7
|
+
from textual.widgets._directory_tree import DirEntry
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from textual.widgets._tree import TreeNode
|
|
14
|
+
|
|
15
|
+
DEFAULT_INT_RESTRICT: str = r"^-?\d*$"
|
|
16
|
+
DEFAULT_FLOAT_RESTRICT: str = r"^-?\d*(\.\d*)?([eE][+-]?\d+)?$"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safe_id(prefix: str, key: str) -> str:
|
|
20
|
+
raw = f"{prefix}_{key}" if prefix else key
|
|
21
|
+
return raw.replace(".", "_")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_scalar_row(
|
|
25
|
+
label: str,
|
|
26
|
+
value: Any,
|
|
27
|
+
*,
|
|
28
|
+
widget_id: str | None = None,
|
|
29
|
+
restrict: str | None = None,
|
|
30
|
+
dotted_path: str | None = None,
|
|
31
|
+
) -> Horizontal:
|
|
32
|
+
widget: Switch | Input
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
widget = Switch(value=value, id=widget_id)
|
|
35
|
+
elif isinstance(value, int):
|
|
36
|
+
widget = Input(
|
|
37
|
+
type="integer",
|
|
38
|
+
value=str(value),
|
|
39
|
+
restrict=restrict or DEFAULT_INT_RESTRICT,
|
|
40
|
+
id=widget_id,
|
|
41
|
+
)
|
|
42
|
+
elif isinstance(value, float):
|
|
43
|
+
widget = Input(
|
|
44
|
+
type="number",
|
|
45
|
+
value=str(value),
|
|
46
|
+
restrict=restrict or DEFAULT_FLOAT_RESTRICT,
|
|
47
|
+
id=widget_id,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
widget = Input(type="text", value=str(value), id=widget_id, restrict=restrict)
|
|
51
|
+
|
|
52
|
+
widget.add_class("scalar-input")
|
|
53
|
+
reset_btn = Button("↺", id=f"reset-{widget_id}" if widget_id else None, classes="reset-btn")
|
|
54
|
+
row = Horizontal(Static(label, classes="scalar-label"), widget, reset_btn, classes="scalar-row")
|
|
55
|
+
row.reset_path = dotted_path # type: ignore[attr-defined]
|
|
56
|
+
return row
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _map_value(
|
|
60
|
+
key: str,
|
|
61
|
+
value: Any,
|
|
62
|
+
*,
|
|
63
|
+
prefix: str = "",
|
|
64
|
+
regex_overrides: dict[str, str] | None = None,
|
|
65
|
+
) -> Horizontal | Collapsible:
|
|
66
|
+
dotted_path = f"{prefix}.{key}" if prefix else key
|
|
67
|
+
wid = _safe_id(prefix, key)
|
|
68
|
+
overrides = regex_overrides or {}
|
|
69
|
+
|
|
70
|
+
if isinstance(value, dict):
|
|
71
|
+
children = [_map_value(k, v, prefix=dotted_path, regex_overrides=overrides) for k, v in value.items()]
|
|
72
|
+
return Collapsible(*children, title=key, id=wid, classes="container-dict", collapsed=False)
|
|
73
|
+
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
children = [
|
|
76
|
+
_map_value(str(i), item, prefix=dotted_path, regex_overrides=overrides) for i, item in enumerate(value)
|
|
77
|
+
]
|
|
78
|
+
title = f"{key} [{len(value)}]"
|
|
79
|
+
return Collapsible(*children, title=title, id=wid, classes="container-list", collapsed=False)
|
|
80
|
+
|
|
81
|
+
restrict = overrides.get(dotted_path)
|
|
82
|
+
return _make_scalar_row(key, value, widget_id=wid, restrict=restrict, dotted_path=dotted_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NavigableDirectoryTree(DirectoryTree):
|
|
86
|
+
def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None:
|
|
87
|
+
node.remove_children()
|
|
88
|
+
if node.data is not None:
|
|
89
|
+
parent_path = node.data.path.parent
|
|
90
|
+
if parent_path != node.data.path:
|
|
91
|
+
node.add("..", data=DirEntry(parent_path), allow_expand=True)
|
|
92
|
+
for path in content:
|
|
93
|
+
node.add(
|
|
94
|
+
path.name,
|
|
95
|
+
data=DirEntry(path),
|
|
96
|
+
allow_expand=self._safe_is_dir(path),
|
|
97
|
+
)
|
|
98
|
+
node.expand()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def map_config_to_widgets(
|
|
102
|
+
data: dict[str, Any],
|
|
103
|
+
*,
|
|
104
|
+
regex_overrides: dict[str, str] | None = None,
|
|
105
|
+
) -> Vertical:
|
|
106
|
+
overrides = regex_overrides or {}
|
|
107
|
+
children = [_map_value(key, value, regex_overrides=overrides) for key, value in data.items()]
|
|
108
|
+
return Vertical(*children, classes="content-wrapper")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: configui
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Dynamic: License
|
|
5
|
+
Dynamic: License-Expression
|
|
6
|
+
Summary: A tool that turns any yaml or json configs into tui
|
|
7
|
+
Author-email: Chenchao Zhao <chenchao.zhao.dev+github@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: <3.14,>=3.12
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: ruamel-yaml>=0.18.0
|
|
12
|
+
Requires-Dist: textual>=1.0.0
|
|
13
|
+
Requires-Dist: tomlkit>=0.13.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ConfigUI
|
|
17
|
+
|
|
18
|
+
[](https://github.com/chenchaozhao/configui/actions/workflows/tests.yml)
|
|
19
|
+
[](https://pypi.org/project/configui/)
|
|
20
|
+
[](https://www.python.org/)
|
|
21
|
+
[](LICENSE)
|
|
22
|
+
|
|
23
|
+
Turn YAML, JSON, and TOML configuration files into a terminal user interface (TUI) — no IDE required.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Install from PyPI
|
|
31
|
+
uv tool install configui
|
|
32
|
+
|
|
33
|
+
# Or with pip
|
|
34
|
+
pip install configui
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
configui path/to/config.yaml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open a configuration file in the interactive TUI editor. Edit values, toggle booleans, navigate nested structures — everything stays type-aware. Try it with the sample configs in `samples/`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
configui samples/training_config.yaml
|
|
47
|
+
configui samples/training_config.json
|
|
48
|
+
configui samples/training_config.toml
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
configui path/to/config.yaml -r # Read-only mode
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- **Type-aware editing** — booleans become checkboxes, numbers get numeric inputs, nested objects become collapsible sections.
|
|
60
|
+
- **Multi-format** — YAML (with comment preservation via `ruamel.yaml`), JSON, and TOML (with `tomlkit`).
|
|
61
|
+
- **Read-only mode** — inspect configs without accidental edits (`-r` flag).
|
|
62
|
+
- **Save or Save As** — overwrite the original or write to a new path.
|
|
63
|
+
- **Comment preservation** — your YAML and TOML comments stay intact.
|
|
64
|
+
|
|
65
|
+
## Supported Formats
|
|
66
|
+
|
|
67
|
+
| Format | Library | Comments preserved |
|
|
68
|
+
|--------|---------|-------------------|
|
|
69
|
+
| YAML | ruamel.yaml | ✅ |
|
|
70
|
+
| JSON | stdlib json | ❌ (not supported by format) |
|
|
71
|
+
| TOML | tomlkit | ✅ |
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv tool install hatch
|
|
77
|
+
hatch run release # fmt → typing → test with coverage
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
configui/__about__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
2
|
+
configui/__init__.py,sha256=Vf7-MY0K_6mZs3IxeAsj4wY0DyW_gQP6iuNhPFE5Iow,70
|
|
3
|
+
configui/__main__.py,sha256=5zmNw8RQWWDg3Mb2Ihd1EyGWkdxhz1LU4m1yGraPlHc,73
|
|
4
|
+
configui/config/__init__.py,sha256=eV9OesaYHtJsSpB3na1cOl9Kg4B5yvU71suddbyZT2A,345
|
|
5
|
+
configui/config/_atomic.py,sha256=sfmCr1vESOaMm6YH1SozH__E8sSRaKfxxO4dagY5qe8,1470
|
|
6
|
+
configui/config/_protocol.py,sha256=UgiLkZhh_-6U8rKpB2tUQrkQhZkmM3B4F6bX0yOlwro,468
|
|
7
|
+
configui/config/formats.py,sha256=51iSfNOf6sEsMXErL6TL4omB2rwGbDJzi830ewVHtMQ,991
|
|
8
|
+
configui/config/json_config.py,sha256=Lyua4WyLpXSG9q9nYM1F7-Uch6jruA6h-JejXAZh1VU,1521
|
|
9
|
+
configui/config/toml_config.py,sha256=aNciaAYNZdTDfk6Zlzx4myYF2Po-aJKEpWGV1Gh5wgQ,1492
|
|
10
|
+
configui/config/yaml_config.py,sha256=np36liRCuCk6b52bToOHANW_Vg7a1HoHE-hM2RpNjnw,1827
|
|
11
|
+
configui/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
configui/tui/app.py,sha256=YB3Z4gGjGHkQut8SjwXLW4KtmTLGNcFKeMGeOVTKnQ0,11463
|
|
13
|
+
configui/tui/cli.py,sha256=vdyd3TlO64_FogTpnLks7yor6O3FbhzckYgabN8YCHo,433
|
|
14
|
+
configui/tui/collector.py,sha256=oGs3310RuHrkBSMcDRBB-Q1l4qEbTui2OAgCj5P5hH4,2418
|
|
15
|
+
configui/tui/screens.py,sha256=f6ETw9nJQVkI9FAwFs9gKpQirv_6j6Jad7mSQr86Uzg,4903
|
|
16
|
+
configui/tui/widgets.py,sha256=n-Tzw3Jo5k5WS-2-icpb-Q2uNB0bgUSJRxk7kQsBRR0,3646
|
|
17
|
+
configui-1.0.0.dist-info/METADATA,sha256=pI5K9OdJXoOJF-30upWBZw5zCcxGsot45Ga1HJfhNLM,2416
|
|
18
|
+
configui-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
+
configui-1.0.0.dist-info/entry_points.txt,sha256=7uS8KCdGBpKbHXJIQXzH_Da9IOjEoYMplByZHpCKZcw,51
|
|
20
|
+
configui-1.0.0.dist-info/licenses/LICENSE,sha256=-T4MqdM5NeuybxUKObsf95pcYmKFa5QpvezUvkJc0fo,1070
|
|
21
|
+
configui-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chenchao Zhao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|