tuix-core 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Custosh
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.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tuix-core
3
+ Version: 0.1.0
4
+ Summary: Core engine for the TUIX framework.
5
+ Home-page: https://github.com/custosh/tuix-core
6
+ Author: custosh
7
+ Author-email: custosh <custosh.x@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/custosh/tuix-core
10
+ Project-URL: Repository, https://github.com/custosh/tuix-core
11
+ Project-URL: Issues, https://github.com/custosh/tuix-core/issues
12
+ Keywords: terminal,ui,cli,layout,engine,tuix
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: wcwidth
17
+ Dynamic: license-file
18
+
19
+ # 🧱 TUIX Core 0.1.0
20
+
21
+ > ⚠️ **ATTENTION:**
22
+ > This version of the library is an **MVP (Minimal Viable Project)** release.
23
+ > Many of the implemented functions exist in the codebase but are **not yet actively used**.
24
+ > This version serves as the foundation for upcoming releases.
25
+
26
+ ---
27
+
28
+ ## 🧩 Overview
29
+
30
+ **TUIX** is a modular terminal UI engine inspired by web technologies.
31
+ It introduces a **DOM-like component system**, a **layout engine**, and a **buffer-based rendering pipeline** for building structured and styled terminal interfaces.
32
+
33
+ ---
34
+
35
+ ## 🚀 Installation
36
+
37
+ You can install this library using:
38
+
39
+ ```bash
40
+ pip install tuix-core
41
+ ```
42
+
43
+ ---
44
+
45
+ # ⚡ Example Usage
46
+
47
+ Even though this MVP version is limited, here’s a small example showing what it can currently do:
48
+
49
+ ```python
50
+ from tuix.core import TuixEngine as Tuix
51
+
52
+ # Initialize the main application engine
53
+ app = Tuix()
54
+
55
+ # Create a new component of type "choice"
56
+ app.components.create(object_type="choice", object_id="choice")
57
+
58
+ # Set component properties
59
+ app.components.set_property(object_id="choice", param="label", value="Test")
60
+
61
+ # Define a list of choices with actions
62
+ app.components.set_property(
63
+ object_id="choice",
64
+ param="choices",
65
+ value=[
66
+ [
67
+ {"name": "Test", "action": "pass"},
68
+ {"name": "Test", "action": "pass"}
69
+ ]
70
+ ]
71
+ )
72
+
73
+ # Align the component to the center using margin mode
74
+ app.layout.margin_mode(object_id="choice", param=["margin_top", "margin_left"], mode="centered")
75
+
76
+ # Render the layout to the terminal
77
+ app.render.draw()
78
+ ```
79
+
80
+ 🖥️ This renders a simple `choice` component in the terminal, centered both vertically and horizontally.
81
+
82
+ ---
83
+
84
+ # ⚙️ Future Plans
85
+
86
+ Both the `LayouEngine` and `RenderEngine` will be rewritten in upcoming versions to create a far more powerful and flexible structure(🤫 won’t spoil the details yet).
87
+
88
+ ---
89
+
90
+ # ☕ Support
91
+
92
+ If this project interests you or helps you in any way,
93
+ you can support its development by buying me a coffee ❤️
94
+
95
+ ---
96
+
97
+ # 📜 License
98
+
99
+ MIT License © 2025 custosh
100
+
101
+ ```text
102
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
103
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
104
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
105
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
106
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
107
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
108
+ SOFTWARE.
109
+ ```
@@ -0,0 +1,91 @@
1
+ # 🧱 TUIX Core 0.1.0
2
+
3
+ > ⚠️ **ATTENTION:**
4
+ > This version of the library is an **MVP (Minimal Viable Project)** release.
5
+ > Many of the implemented functions exist in the codebase but are **not yet actively used**.
6
+ > This version serves as the foundation for upcoming releases.
7
+
8
+ ---
9
+
10
+ ## 🧩 Overview
11
+
12
+ **TUIX** is a modular terminal UI engine inspired by web technologies.
13
+ It introduces a **DOM-like component system**, a **layout engine**, and a **buffer-based rendering pipeline** for building structured and styled terminal interfaces.
14
+
15
+ ---
16
+
17
+ ## 🚀 Installation
18
+
19
+ You can install this library using:
20
+
21
+ ```bash
22
+ pip install tuix-core
23
+ ```
24
+
25
+ ---
26
+
27
+ # ⚡ Example Usage
28
+
29
+ Even though this MVP version is limited, here’s a small example showing what it can currently do:
30
+
31
+ ```python
32
+ from tuix.core import TuixEngine as Tuix
33
+
34
+ # Initialize the main application engine
35
+ app = Tuix()
36
+
37
+ # Create a new component of type "choice"
38
+ app.components.create(object_type="choice", object_id="choice")
39
+
40
+ # Set component properties
41
+ app.components.set_property(object_id="choice", param="label", value="Test")
42
+
43
+ # Define a list of choices with actions
44
+ app.components.set_property(
45
+ object_id="choice",
46
+ param="choices",
47
+ value=[
48
+ [
49
+ {"name": "Test", "action": "pass"},
50
+ {"name": "Test", "action": "pass"}
51
+ ]
52
+ ]
53
+ )
54
+
55
+ # Align the component to the center using margin mode
56
+ app.layout.margin_mode(object_id="choice", param=["margin_top", "margin_left"], mode="centered")
57
+
58
+ # Render the layout to the terminal
59
+ app.render.draw()
60
+ ```
61
+
62
+ 🖥️ This renders a simple `choice` component in the terminal, centered both vertically and horizontally.
63
+
64
+ ---
65
+
66
+ # ⚙️ Future Plans
67
+
68
+ Both the `LayouEngine` and `RenderEngine` will be rewritten in upcoming versions to create a far more powerful and flexible structure(🤫 won’t spoil the details yet).
69
+
70
+ ---
71
+
72
+ # ☕ Support
73
+
74
+ If this project interests you or helps you in any way,
75
+ you can support its development by buying me a coffee ❤️
76
+
77
+ ---
78
+
79
+ # 📜 License
80
+
81
+ MIT License © 2025 custosh
82
+
83
+ ```text
84
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
85
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
86
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
87
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
88
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
89
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
90
+ SOFTWARE.
91
+ ```
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tuix-core"
7
+ version = "0.1.0"
8
+ description = "Core engine for the TUIX framework."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "custosh", email = "custosh.x@gmail.com"}
14
+ ]
15
+ keywords = ["terminal", "ui", "cli", "layout", "engine", "tuix"]
16
+ dependencies = [
17
+ "wcwidth"
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/custosh/tuix-core"
22
+ Repository = "https://github.com/custosh/tuix-core"
23
+ Issues = "https://github.com/custosh/tuix-core/issues"
24
+
25
+ [tool.setuptools.packages.find]
26
+ namespaces = true
@@ -0,0 +1,32 @@
1
+ [metadata]
2
+ name = tuix-core
3
+ version = 0.1.0
4
+ author = custosh
5
+ author_email = custosh.x@gmail.com
6
+ description = Core engine for the TUIX framework.
7
+ long_description = file: README.md
8
+ long_description_content_type = text/markdown
9
+ license = MIT
10
+ url = https://github.com/custosh/tuix-core
11
+ project_urls =
12
+ Repository = https://github.com/custosh/tuix-core
13
+ Issues = https://github.com/custosh/tuix-core/issues
14
+ classifiers =
15
+ Development Status :: 3 - Alpha
16
+ Programming Language :: Python :: 3
17
+ License :: OSI Approved :: MIT License
18
+ Operating System :: OS Independent
19
+ Intended Audience :: Developers
20
+ Topic :: Software Development :: Libraries
21
+ Topic :: Terminals
22
+
23
+ [options]
24
+ packages = find_namespace:
25
+ python_requires = >=3.9
26
+ install_requires =
27
+ wcwidth
28
+
29
+ [egg_info]
30
+ tag_build =
31
+ tag_date = 0
32
+
@@ -0,0 +1,5 @@
1
+ from .core import *
2
+ __all__ = ["Tuix"]
3
+
4
+ __version__ = "0.1.0"
5
+ __author__ = "Custosh"
@@ -0,0 +1,655 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import time
5
+ import shutil
6
+ from typing import Union
7
+ import copy
8
+ import wcwidth
9
+
10
+ if sys.platform == "win32":
11
+ import ctypes
12
+
13
+ kernel32 = ctypes.windll.kernel32
14
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
15
+
16
+ def text_color(r, g, b):
17
+ return f"\033[38;2;{r};{g};{b}m"
18
+
19
+
20
+ def background_color(r, g, b):
21
+ return f"\033[48;2;{r};{g};{b}m"
22
+
23
+
24
+ def is_rgb(value):
25
+ if not (isinstance(value, tuple) and len(value) == 3):
26
+ return False
27
+ for x in value:
28
+ if isinstance(x, float):
29
+ if not 0.0 <= x <= 255.0:
30
+ return False
31
+ elif isinstance(x, int):
32
+ if not 0 <= x <= 255:
33
+ return False
34
+ else:
35
+ return False
36
+ return True
37
+
38
+
39
+ def blend_shadow(bg, fg, intensity=0.3):
40
+ return tuple(
41
+ int(bg[i] * (1 - intensity) + fg[i] * intensity)
42
+ for i in range(3)
43
+ )
44
+
45
+
46
+ def visual_width(s):
47
+ return sum(wcwidth.wcwidth(ch) for ch in s)
48
+
49
+
50
+ class TuixEngine():
51
+ def __init__(self):
52
+ self.styles = Styles(self)
53
+ self.components = ComponentAPI(self)
54
+ self.layout = LayoutEngine(self)
55
+ self.render = RenderEngine(self)
56
+ self.input = InputHandler(self)
57
+
58
+
59
+ class Styles():
60
+ def __init__(self, main):
61
+ self.main = main
62
+ self.types = ["adaptive", "strict"]
63
+ self.styles = ["classic"]
64
+ self.styles_config = {
65
+ "classic": {
66
+ "shadow": False,
67
+ "background": 0,
68
+ "prompt_background": (0, 0, 0),
69
+ "border": (255, 255, 255),
70
+ "text_color": (255, 255, 255),
71
+ "text_background": False,
72
+ "unselected_background": False,
73
+ "unselected_text": (255, 255, 255),
74
+ "selected_background": (255, 255, 255),
75
+ "selected_text": (0, 0, 0),
76
+ "text": {
77
+ "bold": False,
78
+ "italic": False,
79
+ "underline": False,
80
+ "dim": False
81
+ }
82
+ }
83
+ }
84
+ self.type = "strict"
85
+ self.style = "classic"
86
+ self.custom_styles = {
87
+ "shadow": None,
88
+ "background": None,
89
+ "prompt_background": None,
90
+ "border": None,
91
+ "text_color": None,
92
+ "text_background": None,
93
+ "unselected_background": None,
94
+ "unselected_text": None,
95
+ "selected_background": None,
96
+ "selected_text": None,
97
+ "text": {
98
+ "bold": None,
99
+ "italic": None,
100
+ "underline": None,
101
+ "dim": None
102
+ }
103
+ }
104
+ self.cached_styles = self._precompute_styles()
105
+
106
+ def set_type(self, type: str):
107
+ if type in self.types:
108
+ self.type = type
109
+ else:
110
+ raise ValueError(f"Unknown prompt type \"{type}\"")
111
+
112
+ def set_style(self, style: str):
113
+ if style in self.styles:
114
+ self.style = style
115
+ else:
116
+ raise ValueError(f"Unknown prompt style \"{style}\"")
117
+
118
+ def set_custom_style(self, *, key: str, option: str = None, value: Union[tuple, bool]):
119
+ if key in ["background", "prompt_background", "border", "unselected_text", "selected_background",
120
+ "selected_text", "text_color"]:
121
+ self._set_custom_style_onlyrgb_handler(key=key, value=value)
122
+ elif key in ["shadow", "text_background", "unselected_background"]:
123
+ self._set_custom_style_bool_rgb_handler(key=key, value=value)
124
+ elif key in ["text"]:
125
+ self._set_custom_style_options_handler(option=option, value=value, key=key)
126
+ else:
127
+ raise ValueError(f"Unknown key \"{key}\"")
128
+
129
+ def _set_custom_style_onlyrgb_handler(self, *, key: str, value: tuple):
130
+ if is_rgb(value):
131
+ self.custom_styles[key] = value
132
+ else:
133
+ raise ValueError("Value must be rgb tuple")
134
+ self._cache_styles()
135
+
136
+ def _set_custom_style_bool_rgb_handler(self, *, key: str, value: bool):
137
+ if isinstance(value, bool):
138
+ if value:
139
+ if key == "shadow":
140
+ self.custom_styles[key] = blend_shadow(
141
+ self.custom_styles["background"] if self.custom_styles["background"] != None else
142
+ self.styles_config[self.style]["background"],
143
+ self.custom_styles["prompt_background"] if self.custom_styles["prompt_background"] != None else
144
+ self.styles_config[self.style]["prompt_background"])
145
+ elif key in ["text_background", "unselected_background"]:
146
+ raise ValueError("Value can't be True")
147
+ else:
148
+ self.custom_styles[key] = False
149
+ elif is_rgb(value):
150
+ self.custom_styles[key] = value
151
+ else:
152
+ raise ValueError(f"Value must be boolean or rgb tuple")
153
+ self._cache_styles()
154
+
155
+ def _set_custom_style_options_handler(self, *, option: str, value: bool, key: str):
156
+ if option in self.custom_styles[key]:
157
+ if isinstance(value, bool):
158
+ self.custom_styles[key][option] = value
159
+ else:
160
+ raise ValueError("Value must be boolean")
161
+ else:
162
+ raise ValueError(f"Unknown option \"{option}\" for key \"{key}\"")
163
+ self._cache_styles()
164
+
165
+ def remove_custom_style(self, key: Union[str, list], option: Union[str, list] = None):
166
+ if isinstance(key, list):
167
+ for word in key:
168
+ if isinstance(word, str):
169
+ if word in self.custom_styles:
170
+ if word not in ["text"]:
171
+ self.custom_styles[word] = None
172
+ else:
173
+ raise ValueError("This style doesn't support removing using list")
174
+ else:
175
+ if key in self.custom_styles:
176
+ if key not in ["text"]:
177
+ self.custom_styles[key] = None
178
+ else:
179
+ if isinstance(option, str):
180
+ if option in self.custom_styles[key]:
181
+ self.custom_styles[key][option] = None
182
+ elif isinstance(option, list):
183
+ for opt in option:
184
+ if opt in self.custom_styles[key]:
185
+ self.custom_styles[key][opt] = None
186
+ else:
187
+ raise ValueError("Option must be string or list")
188
+
189
+ self._cache_styles()
190
+
191
+ def define_style(self, *, name: str, config: dict):
192
+ keys = []
193
+ new_keys = []
194
+ for name_, data in self.styles_config["classic"].items():
195
+ keys.append(name_)
196
+ for name_, data in config.items():
197
+ new_keys.append(name_)
198
+
199
+ if set(keys) != set(new_keys):
200
+ raise ValueError("Style config keys do not match the required keys")
201
+
202
+ if name not in self.styles_config:
203
+ self.styles_config[name] = config
204
+ self.styles.append(name)
205
+
206
+ def _precompute_styles(self):
207
+ """
208
+ Internal API.
209
+ Computes and returns the fully resolved style dictionary
210
+ (after applying preset + custom cascade) for RenderAPI consumption.
211
+ """
212
+ precomputed_styles = copy.deepcopy(self.styles_config[self.style])
213
+ styles_config = []
214
+ for name, data in self.styles_config[self.style].items():
215
+ styles_config.append(name)
216
+
217
+ for name, data in self.custom_styles.items():
218
+ if name not in styles_config:
219
+ raise ValueError(f"Unknown style key \"{name}\"")
220
+
221
+ if data != None:
222
+ precomputed_styles[name] = data
223
+
224
+ return precomputed_styles
225
+
226
+ def _cache_styles(self):
227
+ """
228
+ Internal API.
229
+ Caches the precomputed styles for faster access.
230
+ """
231
+ self.cached_styles = self._precompute_styles()
232
+
233
+
234
+ class ComponentAPI:
235
+ """ Component management API
236
+ self.objects structure:
237
+ { "id": {"type": "choice", "label": "Select an option:", "choices": [[{name: "Option 1", action: "action_1"}], [{name: "Option 2", action: "action_2"}]...]}} # every sub-list is a 1 row with buttons in menu
238
+ { "id": {"type": "progress_bar", "label": "Loading...", "progress": 50 } }
239
+ { "id": {"type": "text_input", "label": "Enter your name:", "default_text": "" } }
240
+
241
+ ToDo — Validation System Upgrade
242
+ - [ ] Split `set_property()` into type-specific validators:
243
+ - _validate_choice()
244
+ - _validate_progress_bar()
245
+ - _validate_text_input()
246
+ - [ ] Add property schema metadata per component
247
+ - [ ] Introduce validators registry for dynamic dispatch
248
+ - [ ] Prepare testing harness for validator integrity
249
+ """
250
+
251
+ def __init__(self, main):
252
+ self.main = main
253
+ self.objects = {}
254
+ self.types = ["choice", "progress_bar", "text_input"]
255
+ self.properties = {
256
+ "label": self.types,
257
+ "choices": ["choice"],
258
+ "progress": ["progress_bar"],
259
+ "default_text": ["text_input"]
260
+ }
261
+
262
+ def create(self, type: str, id: str, classes: list = []):
263
+ if id in self.objects:
264
+ raise ValueError(f"Object with id \"{id}\" already exists")
265
+ if type not in self.types:
266
+ raise ValueError(f"Unknown object type \"{type}\"")
267
+ self.objects[id] = {"type": type,
268
+ "layout": {"margin_top_mode": "custom", "margin_left_mode": "custom",
269
+ "width_modifier": 0.5, "height_modifier": 0.5, "margin_top_modifier": 0.0,
270
+ "margin_left_modifier": 0.0}}
271
+ if type in self.properties["label"]:
272
+ self.objects[id]["label"] = ""
273
+ if type in self.properties["choices"]:
274
+ self.objects[id]["choices"] = []
275
+ if type in self.properties["progress"]:
276
+ self.objects[id]["progress"] = 0
277
+ if type in self.properties["default_text"]:
278
+ self.objects[id]["default_text"] = ""
279
+
280
+ def set_property(self, *, id: str, param: str, value):
281
+ if id not in self.objects:
282
+ raise ValueError(f"Object with id \"{id}\" does not exist")
283
+ if param not in self.properties:
284
+ raise ValueError(f"Unknown property name \"{param}\"")
285
+ if self.objects[id]["type"] not in self.properties[param]:
286
+ raise ValueError(
287
+ f"Property \"{param}\" is not applicable for object type \"{self.objects[id]['type']}\"")
288
+ self.objects[id][param] = value
289
+
290
+ def get(self, id: str):
291
+ if id not in self.objects:
292
+ raise ValueError(f"Object with id \"{id}\" does not exist")
293
+ return self.objects[id]
294
+
295
+ def delete(self, id: str):
296
+ if id not in self.objects:
297
+ raise ValueError(f"Object with id \"{id}\" does not exist")
298
+ del self.objects[id]
299
+
300
+
301
+ class LayoutEngine():
302
+ """
303
+ Layout management API
304
+ self.objects structure:
305
+ obj["layout"] = {
306
+ "x": width_modifier * terminal_width,
307
+ "y": height_modifier * terminal_height,
308
+ "margin_top": margin_top * terminal_height,
309
+ "margin_left": margin_left * terminal_width,
310
+ "margin_top_mode": "custom"
311
+ "margin_left_mode": "custom"
312
+ "corners": {
313
+ "top_left": (margin_left, margin_top),
314
+ "bottom_right": (margin_left + width_modifier * terminal_width, margin_top + height_modifier * terminal_height),
315
+ }
316
+ }
317
+
318
+ if user want centred object use this:
319
+ obj["layout"] = {
320
+ "x": width_modifier * terminal_width,
321
+ "y": height_modifier * terminal_height,
322
+ "margin_top": (terminal_rows - int(height_modifier * terminal_rows)) // 2,
323
+ "margin_left": (terminal_cols - int(width_modifier * terminal_cols)) // 2,
324
+ "margin_top_mode": "centered"
325
+ "margin_left_mode": "centered"
326
+ "corners": {
327
+ "top_left": (margin_left, margin_top),
328
+ "bottom_right": (margin_left + width_modifier * terminal_width, margin_top + height_modifier * terminal_height),
329
+ }
330
+ }
331
+ """
332
+
333
+ def __init__(self, main):
334
+ self.main = main
335
+ self.objects = self.main.components.objects
336
+
337
+ def set_dimensions(self, *, id: str, width_modifier: float = None, height_modifier: float = None,
338
+ margin_top: float = None, margin_left: float = None):
339
+ if id not in self.objects:
340
+ raise ValueError(f"Object with id \"{id}\" does not exist")
341
+ if width_modifier is None and height_modifier is None and margin_top is None and margin_left is None:
342
+ raise ValueError("At least one dimension parameter must be provided")
343
+
344
+ for param, value in {"width_modifier": width_modifier, "height_modifier": height_modifier,
345
+ "margin_top": margin_top, "margin_left": margin_left}.items():
346
+ if value is not None:
347
+ if (param == "margin_top" and self.objects[id]["layout"]["margin_top_mode"] == "centered") or (
348
+ param == "margin_left" and self.objects[id]["layout"]["margin_left_mode"] == "centered"):
349
+ raise ValueError(f"Cannot set margin when \"{param}\" mode is set to \"centered\"")
350
+ if not (0.0 <= value <= 1.0):
351
+ raise ValueError(f"\"{param}\" parameter must be between 0.0 and 1.0")
352
+
353
+ self.objects[id]["layout"][param] = value
354
+
355
+ def margin_mode(self, *, id: str, param: Union[str, list], mode: str):
356
+ if id not in self.objects:
357
+ raise ValueError(f"Object with id \"{id}\" does not exist")
358
+ if not isinstance(param, list):
359
+ param = [param]
360
+ for value in param:
361
+ if value not in ["margin_top", "margin_left"]:
362
+ raise ValueError(f"Unknown parameter type \"{value}\"")
363
+ if mode not in ["centered", "custom"]:
364
+ raise ValueError(f"Unknown margin mode \"{mode}\" for parameter \"{param}\"")
365
+
366
+ for value in param:
367
+ self.objects[id]["layout"][f"{value}_mode"] = mode
368
+
369
+
370
+ def _compute_all(self):
371
+ terminal_cols, terminal_rows = shutil.get_terminal_size()
372
+ for id, obj in self.objects.items():
373
+ width_modifier = self.objects[id]["layout"]["width_modifier"]
374
+ height_modifier = self.objects[id]["layout"]["height_modifier"]
375
+ margin_top = self.objects[id]["layout"]["margin_top_modifier"]
376
+ margin_left = self.objects[id]["layout"]["margin_left_modifier"]
377
+ self.objects[id]["layout"] = {
378
+ "width_modifier": width_modifier,
379
+ "height_modifier": height_modifier,
380
+ "margin_top_modifier": margin_top,
381
+ "margin_left_modifier": margin_left,
382
+ "margin_top_mode": obj["layout"]["margin_top_mode"],
383
+ "margin_left_mode": obj["layout"]["margin_left_mode"],
384
+ "x": int(width_modifier * terminal_cols),
385
+ "y": int(height_modifier * terminal_rows),
386
+ "margin_top": int(margin_top * terminal_rows) if obj["layout"]["margin_top_mode"] == "custom" else (terminal_rows - int(height_modifier * terminal_rows)) // 2,
387
+ "margin_left": int(margin_left * terminal_cols) if obj["layout"]["margin_left_mode"] == "custom" else (terminal_cols - int(width_modifier * terminal_cols)) // 2,
388
+ "corners": {
389
+ "top_left": (int(margin_left * terminal_cols), int(margin_top * terminal_rows)),
390
+ "bottom_right": (int((margin_left + width_modifier) * terminal_cols),
391
+ int((margin_top + height_modifier) * terminal_rows))
392
+ }
393
+ }
394
+
395
+ class RenderEngine:
396
+ def __init__(self, main):
397
+ self.main = main
398
+ self.objects = self.main.components.objects
399
+ self.selected_row = 0
400
+ self.selected_index = 0
401
+
402
+ def draw(self):
403
+ os.system("cls" if sys.platform == "win32" else "clear")
404
+ self.main.layout._compute_all()
405
+ if len(self.objects) == 1:
406
+ for key, obj in self.objects.items():
407
+ if obj["type"] == "choice":
408
+ print("\n" * obj["layout"]["margin_top"], end="")
409
+ print(" " * obj["layout"]["margin_left"] + "┏" + "━" * (obj["layout"]["x"] - 2) + "┓")
410
+ self._draw_choice(obj, obj["label"])
411
+ else:
412
+ raise NotImplementedError("Only choice prompt is available now")
413
+ elif len(self.objects) == 0:
414
+ raise ValueError("Must be initialized at least 1 object")
415
+ else:
416
+ raise NotImplementedError("Multi-modal layout system is still in development")
417
+
418
+ def _wrap_and_center(self, text: str, max_width: int) -> list[str]:
419
+ """Wraps text to max_width, but centers block horizontally."""
420
+ tokens = []
421
+ for part in text.split("\n"):
422
+ if part:
423
+ tokens.extend(re.findall(r'\S+|\s+', part))
424
+ tokens.append("\n")
425
+
426
+ lines, current, line_len = [], "", 0
427
+ for token in tokens:
428
+ if token == "\n":
429
+ lines.append(current.rstrip())
430
+ current = ""
431
+ line_len = 0
432
+ continue
433
+ token_len = len(token)
434
+ if line_len + token_len > max_width:
435
+ lines.append(current.rstrip())
436
+ current = token
437
+ line_len = token_len
438
+ else:
439
+ current += token
440
+ line_len += token_len
441
+ if current.strip():
442
+ lines.append(current.rstrip())
443
+
444
+ left_pad = 0
445
+ if lines:
446
+ gap = max_width - len(lines[0])
447
+ left_pad = gap // 2
448
+
449
+ return [(" " * left_pad + line + " " * (max_width - len(line) - left_pad)) for line in lines]
450
+
451
+ def _draw_buttons(self, *, obj, choices: list, max_width: int, max_height: int) -> None:
452
+ if not choices:
453
+ raise ValueError("Choices list can't be empty")
454
+
455
+ layout = obj["layout"]
456
+ rendered_rows = []
457
+
458
+ for row in choices:
459
+ row_parts = []
460
+ for choice in row:
461
+ text = choice["name"]
462
+ if visual_width(text) > max_width - 4:
463
+ chunk, pieces = "", []
464
+ for ch in text:
465
+ if visual_width(chunk + ch) >= max_width - 4:
466
+ pieces.append(chunk)
467
+ chunk = ch
468
+ else:
469
+ chunk += ch
470
+ if chunk:
471
+ pieces.append(chunk)
472
+ text = " ".join(pieces)
473
+ row_parts.append(text)
474
+ rendered_rows.append(" ".join(row_parts))
475
+
476
+ total_rows = min(len(rendered_rows), max_height)
477
+ visible_rows = rendered_rows[-total_rows:]
478
+ start_y = max_height - total_rows
479
+ lines_to_render = []
480
+
481
+ for idx, text_line in enumerate(visible_rows):
482
+ row_width = visual_width(text_line)
483
+ left_offset = max((max_width - row_width) // 2, 0)
484
+ lines_to_render.append({
485
+ "text": text_line,
486
+ "left_offset": left_offset,
487
+ "y_offset": start_y + idx,
488
+ })
489
+
490
+ print(
491
+ (" " * layout["margin_left"] + "┃" + " " * (layout["x"] - 2) + "┃\n")
492
+ * (max_height - len(lines_to_render) * 2),
493
+ end="",
494
+ )
495
+
496
+ for row_idx, line in enumerate(lines_to_render):
497
+ inner_space_left = " " * line["left_offset"]
498
+ inner_space_right = " " * (
499
+ layout["x"] - 2 - line["left_offset"] - visual_width(line["text"])
500
+ )
501
+
502
+ if row_idx == self.selected_row:
503
+ highlighted = ""
504
+ segments = line["text"].split(" ")
505
+ for idx, segment in enumerate(segments):
506
+ if idx == self.selected_index:
507
+ background_col = self.main.styles.custom_styles['selected_background'] if is_rgb(self.main.styles.custom_styles['selected_background']) else self.main.styles.styles_config[self.main.styles.style]['selected_background']
508
+ text_col = self.main.styles.custom_styles['selected_text'] if is_rgb(self.main.styles.custom_styles['selected_text']) else self.main.styles.styles_config[self.main.styles.style]['selected_text']
509
+
510
+ highlighted += ((" " if idx != 0 else "") + f"{background_color(*background_col)}{text_color(*text_col)}{segment.strip()}\x1b[0m" + (" " if idx == 0 else ""))
511
+ else:
512
+ highlighted += f"{segment.strip()}"
513
+ line_text = highlighted.rstrip()
514
+ else:
515
+ line_text = line["text"]
516
+
517
+ print(
518
+ " " * layout["margin_left"]
519
+ + "┃"
520
+ + inner_space_left
521
+ + line_text
522
+ + inner_space_right
523
+ + "┃"
524
+ )
525
+ print(
526
+ " " * layout["margin_left"] + "┃" + " " * (layout["x"] - 2) + "┃"
527
+ )
528
+
529
+ def _draw_choice(self, obj, text: str):
530
+ text = self._wrap_and_center(text=text, max_width=(obj["layout"]["x"] - 4))
531
+ layout = obj["layout"]
532
+
533
+ print(" " * layout["margin_left"] + "┃" + " " * (layout["x"] - 2) + "┃")
534
+ for line in text:
535
+ print(" " * layout["margin_left"] + "┃ " + line + " ┃")
536
+ print(" " * layout["margin_left"] + "┃" + " " * (layout["x"] - 2) + "┃")
537
+
538
+ self._draw_buttons(
539
+ obj=obj,
540
+ choices=obj["choices"],
541
+ max_height=(layout["y"] - len(text) - 5),
542
+ max_width=(layout["x"] - 4),
543
+ )
544
+
545
+ print(" " * layout["margin_left"] + "┃" + " " * (layout["x"] - 2) + "┃")
546
+ print(" " * layout["margin_left"] + "┗" + "━" * (layout["x"] - 2) + "┛")
547
+
548
+ self.main.input.listen(choices=obj["choices"])
549
+
550
+ def _handle_selection_change(self, key: str, choices: list):
551
+ if not choices:
552
+ return
553
+
554
+ if key == "up":
555
+ self.selected_row = max(0, self.selected_row - 1)
556
+ self.selected_index = min(
557
+ self.selected_index, len(choices[self.selected_row]) - 1
558
+ )
559
+ elif key == "down":
560
+ self.selected_row = min(len(choices) - 1, self.selected_row + 1)
561
+ self.selected_index = min(
562
+ self.selected_index, len(choices[self.selected_row]) - 1
563
+ )
564
+ elif key == "left":
565
+ self.selected_index = max(0, self.selected_index - 1)
566
+ elif key == "right":
567
+ self.selected_index = min(
568
+ len(choices[self.selected_row]) - 1, self.selected_index + 1
569
+ )
570
+
571
+ self._refresh(selected_row=self.selected_row, selected_index=self.selected_index)
572
+
573
+ def _refresh(self, *, selected_row=None, selected_index=None):
574
+ if selected_row is not None:
575
+ self.selected_row = selected_row
576
+ if selected_index is not None:
577
+ self.selected_index = selected_index
578
+
579
+ os.system("cls" if sys.platform == "win32" else "clear")
580
+ self.draw()
581
+
582
+ class InputHandler:
583
+ def __init__(self, main):
584
+ self.main = main
585
+ self.selected_row = 0
586
+ self.selected_index = 0
587
+ self.running = True
588
+
589
+ def get_key(self):
590
+ if sys.platform == "win32":
591
+ import msvcrt
592
+ if msvcrt.kbhit():
593
+ key = msvcrt.getch()
594
+ if key in (b"\xe0", b"\x00"):
595
+ key = msvcrt.getch()
596
+ code = key.decode(errors="ignore")
597
+ mapping = {
598
+ "H": "up",
599
+ "P": "down",
600
+ "K": "left",
601
+ "M": "right"
602
+ }
603
+ return mapping.get(code)
604
+ elif key in (b"\r", b"\n"):
605
+ return "enter"
606
+ return None
607
+
608
+ else:
609
+ import termios, tty, select, sys as _sys
610
+ fd = _sys.stdin.fileno()
611
+ old_settings = termios.tcgetattr(fd)
612
+ try:
613
+ tty.setraw(fd)
614
+ rlist, _, _ = select.select([_sys.stdin], [], [], 0.1)
615
+ if rlist:
616
+ ch = _sys.stdin.read(1)
617
+ if ch == "\x1b":
618
+ seq = _sys.stdin.read(2)
619
+ mapping = {
620
+ "[A": "up",
621
+ "[B": "down",
622
+ "[C": "right",
623
+ "[D": "left"
624
+ }
625
+ return mapping.get(seq)
626
+ elif ch in ["\r", "\n"]:
627
+ return "enter"
628
+ return None
629
+ finally:
630
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
631
+
632
+
633
+ def listen(self, choices:list):
634
+ while self.running:
635
+ key = self.get_key()
636
+ if not key:
637
+ time.sleep(0.05)
638
+ continue
639
+
640
+ if key == "up":
641
+ self.selected_row = max(0, self.selected_row - 1)
642
+ self.selected_index = 0
643
+ elif key == "down":
644
+ self.selected_row = min(len(choices) - 1, self.selected_row + 1)
645
+ self.selected_index = 0
646
+ elif key == "left":
647
+ self.selected_index = max(0, self.selected_index - 1)
648
+ elif key == "right":
649
+ self.selected_index = min(len(choices[self.selected_row]) - 1, self.selected_index + 1)
650
+ elif key == "enter":
651
+ os.system("cls" if sys.platform == "win32" else "clear")
652
+ print(f"Selected index: {self.selected_index}")
653
+ self.running = False
654
+
655
+ self.main.render._refresh(selected_row=self.selected_row, selected_index=self.selected_index)
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tuix-core
3
+ Version: 0.1.0
4
+ Summary: Core engine for the TUIX framework.
5
+ Home-page: https://github.com/custosh/tuix-core
6
+ Author: custosh
7
+ Author-email: custosh <custosh.x@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/custosh/tuix-core
10
+ Project-URL: Repository, https://github.com/custosh/tuix-core
11
+ Project-URL: Issues, https://github.com/custosh/tuix-core/issues
12
+ Keywords: terminal,ui,cli,layout,engine,tuix
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: wcwidth
17
+ Dynamic: license-file
18
+
19
+ # 🧱 TUIX Core 0.1.0
20
+
21
+ > ⚠️ **ATTENTION:**
22
+ > This version of the library is an **MVP (Minimal Viable Project)** release.
23
+ > Many of the implemented functions exist in the codebase but are **not yet actively used**.
24
+ > This version serves as the foundation for upcoming releases.
25
+
26
+ ---
27
+
28
+ ## 🧩 Overview
29
+
30
+ **TUIX** is a modular terminal UI engine inspired by web technologies.
31
+ It introduces a **DOM-like component system**, a **layout engine**, and a **buffer-based rendering pipeline** for building structured and styled terminal interfaces.
32
+
33
+ ---
34
+
35
+ ## 🚀 Installation
36
+
37
+ You can install this library using:
38
+
39
+ ```bash
40
+ pip install tuix-core
41
+ ```
42
+
43
+ ---
44
+
45
+ # ⚡ Example Usage
46
+
47
+ Even though this MVP version is limited, here’s a small example showing what it can currently do:
48
+
49
+ ```python
50
+ from tuix.core import TuixEngine as Tuix
51
+
52
+ # Initialize the main application engine
53
+ app = Tuix()
54
+
55
+ # Create a new component of type "choice"
56
+ app.components.create(object_type="choice", object_id="choice")
57
+
58
+ # Set component properties
59
+ app.components.set_property(object_id="choice", param="label", value="Test")
60
+
61
+ # Define a list of choices with actions
62
+ app.components.set_property(
63
+ object_id="choice",
64
+ param="choices",
65
+ value=[
66
+ [
67
+ {"name": "Test", "action": "pass"},
68
+ {"name": "Test", "action": "pass"}
69
+ ]
70
+ ]
71
+ )
72
+
73
+ # Align the component to the center using margin mode
74
+ app.layout.margin_mode(object_id="choice", param=["margin_top", "margin_left"], mode="centered")
75
+
76
+ # Render the layout to the terminal
77
+ app.render.draw()
78
+ ```
79
+
80
+ 🖥️ This renders a simple `choice` component in the terminal, centered both vertically and horizontally.
81
+
82
+ ---
83
+
84
+ # ⚙️ Future Plans
85
+
86
+ Both the `LayouEngine` and `RenderEngine` will be rewritten in upcoming versions to create a far more powerful and flexible structure(🤫 won’t spoil the details yet).
87
+
88
+ ---
89
+
90
+ # ☕ Support
91
+
92
+ If this project interests you or helps you in any way,
93
+ you can support its development by buying me a coffee ❤️
94
+
95
+ ---
96
+
97
+ # 📜 License
98
+
99
+ MIT License © 2025 custosh
100
+
101
+ ```text
102
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
103
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
104
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
105
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
106
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
107
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
108
+ SOFTWARE.
109
+ ```
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ tuix/core/__init__.py
6
+ tuix/core/core.py
7
+ tuix_core.egg-info/PKG-INFO
8
+ tuix_core.egg-info/SOURCES.txt
9
+ tuix_core.egg-info/dependency_links.txt
10
+ tuix_core.egg-info/requires.txt
11
+ tuix_core.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ wcwidth
@@ -0,0 +1,2 @@
1
+ dist
2
+ tuix