casca 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.
- casca-0.1.0/LICENSE +21 -0
- casca-0.1.0/PKG-INFO +80 -0
- casca-0.1.0/README.md +56 -0
- casca-0.1.0/casca/__init__.py +37 -0
- casca-0.1.0/casca/components/__init__.py +1 -0
- casca-0.1.0/casca/components/base.py +78 -0
- casca-0.1.0/casca/components/forms.py +101 -0
- casca-0.1.0/casca/components/panels.py +26 -0
- casca-0.1.0/casca/components/scroll.py +148 -0
- casca-0.1.0/casca/core/__init__.py +0 -0
- casca-0.1.0/casca/core/ansi.py +48 -0
- casca-0.1.0/casca/core/app.py +236 -0
- casca-0.1.0/casca/core/events.py +18 -0
- casca-0.1.0/casca/core/terminal.py +48 -0
- casca-0.1.0/casca/dom/__init__.py +0 -0
- casca-0.1.0/casca/dom/node.py +184 -0
- casca-0.1.0/casca/style/__init__.py +0 -0
- casca-0.1.0/casca/style/css.py +57 -0
- casca-0.1.0/casca.egg-info/PKG-INFO +80 -0
- casca-0.1.0/casca.egg-info/SOURCES.txt +24 -0
- casca-0.1.0/casca.egg-info/dependency_links.txt +1 -0
- casca-0.1.0/casca.egg-info/requires.txt +8 -0
- casca-0.1.0/casca.egg-info/top_level.txt +1 -0
- casca-0.1.0/pyproject.toml +34 -0
- casca-0.1.0/setup.cfg +4 -0
- casca-0.1.0/tests/test_imports.py +12 -0
casca-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Casca Contributors
|
|
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.
|
casca-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: casca
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native Python CLI UI library with CSS-like styling
|
|
5
|
+
Author-email: Abdallah <abdalla.zain2004@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: tui,cli,terminal,ui,css,python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: chatbot
|
|
18
|
+
Requires-Dist: groq>=0.20.0; extra == "chatbot"
|
|
19
|
+
Provides-Extra: docs
|
|
20
|
+
Requires-Dist: mkdocs>=1.6.0; extra == "docs"
|
|
21
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == "docs"
|
|
22
|
+
Requires-Dist: pymdown-extensions>=10.8.0; extra == "docs"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Casca
|
|
26
|
+
|
|
27
|
+
Casca is a lightweight, native Python TUI library with a DOM-like widget tree and CSS-style theming.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Zero core dependencies (standard library runtime)
|
|
32
|
+
- Declarative widgets (`Container`, `Label`, `Input`, `Button`, `Card`, `Header`, `ScrollView`)
|
|
33
|
+
- CSS-like style parsing and cascade
|
|
34
|
+
- Keyboard and mouse event handling in raw terminal mode
|
|
35
|
+
- Real examples including login screen, dashboard, and Groq chatbot
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from casca import Container, Label, run_app
|
|
41
|
+
|
|
42
|
+
CSS = """
|
|
43
|
+
#root {
|
|
44
|
+
padding: 1;
|
|
45
|
+
border: ascii;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ui_tree = Container(
|
|
52
|
+
Label("Hello from Casca"),
|
|
53
|
+
id="root",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
run_app(ui_tree, css=CSS)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Run Use Cases
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python3 -m use_cases.login_screen
|
|
64
|
+
python3 -m use_cases.dashboard
|
|
65
|
+
python3 -m use_cases.chatbot
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`use_cases.chatbot` requires `groq` and a `GROQ_API_KEY`.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## Project Layout
|
|
72
|
+
|
|
73
|
+
- `casca/`: core library
|
|
74
|
+
- `use_cases/`: real apps built with Casca
|
|
75
|
+
- `examples/`: smaller demos
|
|
76
|
+
- `docs/`: MkDocs content
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
casca-0.1.0/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Casca
|
|
2
|
+
|
|
3
|
+
Casca is a lightweight, native Python TUI library with a DOM-like widget tree and CSS-style theming.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Zero core dependencies (standard library runtime)
|
|
8
|
+
- Declarative widgets (`Container`, `Label`, `Input`, `Button`, `Card`, `Header`, `ScrollView`)
|
|
9
|
+
- CSS-like style parsing and cascade
|
|
10
|
+
- Keyboard and mouse event handling in raw terminal mode
|
|
11
|
+
- Real examples including login screen, dashboard, and Groq chatbot
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from casca import Container, Label, run_app
|
|
17
|
+
|
|
18
|
+
CSS = """
|
|
19
|
+
#root {
|
|
20
|
+
padding: 1;
|
|
21
|
+
border: ascii;
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ui_tree = Container(
|
|
28
|
+
Label("Hello from Casca"),
|
|
29
|
+
id="root",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
run_app(ui_tree, css=CSS)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Run Use Cases
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python3 -m use_cases.login_screen
|
|
40
|
+
python3 -m use_cases.dashboard
|
|
41
|
+
python3 -m use_cases.chatbot
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`use_cases.chatbot` requires `groq` and a `GROQ_API_KEY`.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## Project Layout
|
|
48
|
+
|
|
49
|
+
- `casca/`: core library
|
|
50
|
+
- `use_cases/`: real apps built with Casca
|
|
51
|
+
- `examples/`: smaller demos
|
|
52
|
+
- `docs/`: MkDocs content
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .core.app import App
|
|
2
|
+
from .core.events import KeyEvent, ResizeEvent
|
|
3
|
+
from .core.terminal import RawTerminal, get_terminal_size
|
|
4
|
+
from .core.ansi import color, Color, BackColor, move_cursor, CLEAR_SCREEN, RESET
|
|
5
|
+
from .dom.node import Widget
|
|
6
|
+
from .components.base import Container, Label, Button
|
|
7
|
+
from .components.forms import Checkbox, Input
|
|
8
|
+
from .components.panels import Card, Header
|
|
9
|
+
from .components.scroll import ScrollView
|
|
10
|
+
from .style.css import parse_css
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"App",
|
|
14
|
+
"KeyEvent",
|
|
15
|
+
"ResizeEvent",
|
|
16
|
+
"RawTerminal",
|
|
17
|
+
"get_terminal_size",
|
|
18
|
+
"color",
|
|
19
|
+
"Color",
|
|
20
|
+
"BackColor",
|
|
21
|
+
"move_cursor",
|
|
22
|
+
"CLEAR_SCREEN",
|
|
23
|
+
"RESET",
|
|
24
|
+
"Widget",
|
|
25
|
+
"Container",
|
|
26
|
+
"Label",
|
|
27
|
+
"Button",
|
|
28
|
+
"Checkbox",
|
|
29
|
+
"Input",
|
|
30
|
+
"Card",
|
|
31
|
+
"Header",
|
|
32
|
+
"ScrollView",
|
|
33
|
+
"parse_css",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
from .core.app import run_app
|
|
37
|
+
__all__.append('run_app')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .scroll import ScrollView
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from ..dom.node import Widget
|
|
2
|
+
from ..core.ansi import move_cursor
|
|
3
|
+
from ..core.events import MouseEvent
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
class Container(Widget):
|
|
7
|
+
"""A generic div-like container for laying out children."""
|
|
8
|
+
tag = 'container'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Label(Widget):
|
|
12
|
+
"""Displays text. Implements text-wrapping based on layout boundaries."""
|
|
13
|
+
tag = 'label'
|
|
14
|
+
|
|
15
|
+
def __init__(self, text: str, *args, **kwargs):
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
self.text = text
|
|
18
|
+
self.lines = []
|
|
19
|
+
|
|
20
|
+
def calculate_layout(self, available_width: int, available_height: int) -> tuple[int, int]:
|
|
21
|
+
padding = int(self.style.get('padding', 0))
|
|
22
|
+
margin = int(self.style.get('margin', 0))
|
|
23
|
+
|
|
24
|
+
# Calculate available inner width to check wrapping
|
|
25
|
+
inner_width = available_width - (margin * 2) - (padding * 2)
|
|
26
|
+
w_prop = self.props.get('width') or self.style.get('width')
|
|
27
|
+
if w_prop:
|
|
28
|
+
inner_width = int(w_prop) - (padding * 2)
|
|
29
|
+
|
|
30
|
+
# Parse Text
|
|
31
|
+
self.lines = []
|
|
32
|
+
for line in self.text.split('\\n'):
|
|
33
|
+
if inner_width > 0:
|
|
34
|
+
wrapped = textwrap.wrap(line, max(1, inner_width))
|
|
35
|
+
if not wrapped:
|
|
36
|
+
self.lines.append("")
|
|
37
|
+
else:
|
|
38
|
+
self.lines.extend(wrapped)
|
|
39
|
+
else:
|
|
40
|
+
self.lines.append(line)
|
|
41
|
+
|
|
42
|
+
self.content_height = len(self.lines)
|
|
43
|
+
self.content_width = max([len(l) for l in self.lines] + [0])
|
|
44
|
+
|
|
45
|
+
self.width = self.content_width + padding * 2
|
|
46
|
+
self.height = self.content_height + padding * 2
|
|
47
|
+
|
|
48
|
+
return self.width + margin * 2, self.height + margin * 2
|
|
49
|
+
|
|
50
|
+
def render_content(self, app):
|
|
51
|
+
padding = int(self.style.get('padding', 0))
|
|
52
|
+
|
|
53
|
+
for i, line in enumerate(self.lines):
|
|
54
|
+
app.draw_text(self.x + padding + 1, self.y + padding + i + 1, line)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Button(Label):
|
|
58
|
+
"""Clickable element. Triggers on_click function."""
|
|
59
|
+
tag = 'button'
|
|
60
|
+
|
|
61
|
+
def __init__(self, text: str, *args, on_click=None, **kwargs):
|
|
62
|
+
super().__init__(text, *args, **kwargs)
|
|
63
|
+
self.on_click = on_click
|
|
64
|
+
|
|
65
|
+
def handle_input(self, event) -> bool:
|
|
66
|
+
if event.key == '\r' or event.key == ' ': # Enter or Space
|
|
67
|
+
if self.on_click:
|
|
68
|
+
self.on_click(event)
|
|
69
|
+
return True
|
|
70
|
+
return super().handle_input(event)
|
|
71
|
+
|
|
72
|
+
def on_mouse(self, event: MouseEvent) -> bool:
|
|
73
|
+
# Trigger on mouse release for primary button
|
|
74
|
+
if event.button == 0 and not event.pressed:
|
|
75
|
+
if self.on_click:
|
|
76
|
+
self.on_click(event)
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from ..dom.node import Widget
|
|
2
|
+
from ..core.events import KeyEvent, MouseEvent
|
|
3
|
+
from ..core.ansi import move_cursor
|
|
4
|
+
from .base import Label, Container
|
|
5
|
+
|
|
6
|
+
class Checkbox(Widget):
|
|
7
|
+
"""A selectable checkbox."""
|
|
8
|
+
tag = 'checkbox'
|
|
9
|
+
|
|
10
|
+
def __init__(self, label_text: str, checked: bool = False, *args, on_change=None, **kwargs):
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
self.label_text = label_text
|
|
13
|
+
self.checked = checked
|
|
14
|
+
self.on_change = on_change
|
|
15
|
+
|
|
16
|
+
def calculate_layout(self, available_width: int, available_height: int) -> tuple[int, int]:
|
|
17
|
+
self.content_height = 1
|
|
18
|
+
self.content_width = len(self.label_text) + 4 # "[x] " + text
|
|
19
|
+
|
|
20
|
+
padding = int(self.style.get('padding', 0))
|
|
21
|
+
margin = int(self.style.get('margin', 0))
|
|
22
|
+
|
|
23
|
+
self.width = self.content_width + padding * 2
|
|
24
|
+
self.height = self.content_height + padding * 2
|
|
25
|
+
return self.width + margin * 2, self.height + margin * 2
|
|
26
|
+
|
|
27
|
+
def render_content(self, app):
|
|
28
|
+
padding = int(self.style.get('padding', 0))
|
|
29
|
+
state_char = 'x' if self.checked else ' '
|
|
30
|
+
|
|
31
|
+
app.draw_text(self.x + padding + 1, self.y + padding + 1, f"[{state_char}] {self.label_text}")
|
|
32
|
+
|
|
33
|
+
def handle_input(self, event: KeyEvent) -> bool:
|
|
34
|
+
if event.key == '\r' or event.key == ' ': # Toggle on Enter or Space
|
|
35
|
+
self.checked = not self.checked
|
|
36
|
+
if self.on_change:
|
|
37
|
+
self.on_change(self.checked)
|
|
38
|
+
return True
|
|
39
|
+
return super().handle_input(event)
|
|
40
|
+
|
|
41
|
+
def on_mouse(self, event: MouseEvent) -> bool:
|
|
42
|
+
# Toggle on left click release
|
|
43
|
+
if event.button == 0 and not event.pressed:
|
|
44
|
+
self.checked = not self.checked
|
|
45
|
+
if self.on_change:
|
|
46
|
+
self.on_change(self.checked)
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
class Input(Widget):
|
|
51
|
+
"""A simple one-line text input field."""
|
|
52
|
+
tag = 'input'
|
|
53
|
+
|
|
54
|
+
def __init__(self, placeholder: str = "", default_value: str = "", *args, on_submit=None, **kwargs):
|
|
55
|
+
super().__init__(*args, **kwargs)
|
|
56
|
+
self.placeholder = placeholder
|
|
57
|
+
self.value = default_value
|
|
58
|
+
self.on_submit = on_submit
|
|
59
|
+
|
|
60
|
+
# Make input take up full remaining width by default unless specified
|
|
61
|
+
if 'width' not in self.props:
|
|
62
|
+
self.style['width'] = "20"
|
|
63
|
+
|
|
64
|
+
def calculate_layout(self, available_width: int, available_height: int) -> tuple[int, int]:
|
|
65
|
+
self.content_height = 1
|
|
66
|
+
padding = int(self.style.get('padding', 0))
|
|
67
|
+
margin = int(self.style.get('margin', 0))
|
|
68
|
+
|
|
69
|
+
self.width = self.props.get('width', int(self.style.get('width', available_width)))
|
|
70
|
+
self.height = self.content_height + padding * 2
|
|
71
|
+
|
|
72
|
+
return self.width + margin * 2, self.height + margin * 2
|
|
73
|
+
|
|
74
|
+
def render_content(self, app):
|
|
75
|
+
padding = int(self.style.get('padding', 0))
|
|
76
|
+
|
|
77
|
+
display_text = self.value if self.value else self.placeholder
|
|
78
|
+
|
|
79
|
+
if self.focused:
|
|
80
|
+
display_text += "█"
|
|
81
|
+
|
|
82
|
+
app.draw_text(self.x + padding + 1, self.y + padding + 1, display_text[:self.width - (padding * 2)])
|
|
83
|
+
|
|
84
|
+
def on_mouse(self, event: MouseEvent) -> bool:
|
|
85
|
+
if event.pressed:
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def handle_input(self, event: KeyEvent) -> bool:
|
|
90
|
+
# Simplistic typing
|
|
91
|
+
if event.key in ('\x08', '\x7f', '\b'): # Backspace / Delete
|
|
92
|
+
self.value = self.value[:-1]
|
|
93
|
+
return True
|
|
94
|
+
elif event.key in ('\r', '\n'):
|
|
95
|
+
if self.on_submit:
|
|
96
|
+
self.on_submit(self.value)
|
|
97
|
+
return True
|
|
98
|
+
elif event.is_printable:
|
|
99
|
+
self.value += event.key
|
|
100
|
+
return True
|
|
101
|
+
return super().handle_input(event)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .base import Container, Label
|
|
2
|
+
|
|
3
|
+
class Card(Container):
|
|
4
|
+
"""A Container with predefined standard styling parameters for typical Card UI."""
|
|
5
|
+
tag = 'card'
|
|
6
|
+
|
|
7
|
+
def __init__(self, title: str, *children, **kwargs):
|
|
8
|
+
# Insert a Label as the first child to serve as the card title
|
|
9
|
+
title_label = Label(f" {title} ", classes="card-title")
|
|
10
|
+
super().__init__(title_label, *children, **kwargs)
|
|
11
|
+
|
|
12
|
+
def resolve_styles(self, stylesheet: dict):
|
|
13
|
+
# We can dynamically force a border on cards if not overriden
|
|
14
|
+
if 'border' not in self.style:
|
|
15
|
+
self.style['border'] = 'ascii'
|
|
16
|
+
super().resolve_styles(stylesheet)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Header(Container):
|
|
20
|
+
"""A wide container designed to sit at the top of an app."""
|
|
21
|
+
tag = 'header'
|
|
22
|
+
|
|
23
|
+
def resolve_styles(self, stylesheet: dict):
|
|
24
|
+
# A header should naturally want to span 100% of width
|
|
25
|
+
# (Though our layout engine is basic right now, we can set width to a high number or handled by parent layout later)
|
|
26
|
+
super().resolve_styles(stylesheet)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from ..dom.node import Widget
|
|
2
|
+
from ..core.events import KeyEvent, MouseEvent
|
|
3
|
+
from ..core.ansi import RESET
|
|
4
|
+
|
|
5
|
+
class ScrollView(Widget):
|
|
6
|
+
"""A container that clips its children vertically and allows scrolling."""
|
|
7
|
+
tag = 'scrollview'
|
|
8
|
+
|
|
9
|
+
def __init__(self, *args, **kwargs):
|
|
10
|
+
super().__init__(*args, **kwargs)
|
|
11
|
+
self.scroll_y = 0
|
|
12
|
+
self.max_scroll = 0
|
|
13
|
+
|
|
14
|
+
def calculate_layout(self, available_width: int, available_height: int) -> tuple[int, int]:
|
|
15
|
+
padding = int(self.style.get('padding', 0))
|
|
16
|
+
margin = int(self.style.get('margin', 0))
|
|
17
|
+
border_width = 1 if 'border' in self.style else 0
|
|
18
|
+
|
|
19
|
+
self.width = self.props.get('width', int(self.style.get('width', available_width)))
|
|
20
|
+
|
|
21
|
+
set_height = self.props.get('height') or self.style.get('height')
|
|
22
|
+
if set_height:
|
|
23
|
+
self.height = int(set_height)
|
|
24
|
+
else:
|
|
25
|
+
self.height = available_height
|
|
26
|
+
|
|
27
|
+
inner_width = max(0, self.width - 2*(padding + border_width))
|
|
28
|
+
|
|
29
|
+
current_y = 0
|
|
30
|
+
max_child_width = 0
|
|
31
|
+
|
|
32
|
+
# Calculate children sizes assuming infinite height
|
|
33
|
+
for child in self.children:
|
|
34
|
+
child.x = self.x + padding + border_width
|
|
35
|
+
child.y = self.y + padding + border_width + current_y
|
|
36
|
+
|
|
37
|
+
child_w, child_h = child.calculate_layout(inner_width, 9999)
|
|
38
|
+
current_y += child_h
|
|
39
|
+
max_child_width = max(max_child_width, child_w)
|
|
40
|
+
|
|
41
|
+
self.content_height = current_y
|
|
42
|
+
self.content_width = max_child_width
|
|
43
|
+
|
|
44
|
+
inner_height = max(0, self.height - 2*(padding + border_width))
|
|
45
|
+
self.max_scroll = max(0, self.content_height - inner_height)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Determine if content grew to auto-scroll if requested
|
|
49
|
+
was_at_bottom = hasattr(self, '_last_max') and self.scroll_y >= self._last_max
|
|
50
|
+
is_first_layout = not hasattr(self, '_last_max')
|
|
51
|
+
|
|
52
|
+
if self.props.get('scroll_to_bottom'):
|
|
53
|
+
if is_first_layout or was_at_bottom:
|
|
54
|
+
self.scroll_y = self.max_scroll
|
|
55
|
+
|
|
56
|
+
self._last_max = self.max_scroll
|
|
57
|
+
|
|
58
|
+
if self.scroll_y > self.max_scroll:
|
|
59
|
+
self.scroll_y = self.max_scroll
|
|
60
|
+
if self.scroll_y < 0:
|
|
61
|
+
self.scroll_y = 0
|
|
62
|
+
|
|
63
|
+
# Apply scroll offset conceptually (modifying y directly for rendering)
|
|
64
|
+
# Note: If layout recalculates periodically, this is fine because we recalculate before render
|
|
65
|
+
self._apply_scroll_offset()
|
|
66
|
+
|
|
67
|
+
return self.width + margin * 2, self.height + margin * 2
|
|
68
|
+
|
|
69
|
+
def _apply_scroll_offset(self):
|
|
70
|
+
def shift(node, offset):
|
|
71
|
+
node.y -= offset
|
|
72
|
+
for c in node.children:
|
|
73
|
+
shift(c, offset)
|
|
74
|
+
|
|
75
|
+
for child in self.children:
|
|
76
|
+
shift(child, self.scroll_y)
|
|
77
|
+
|
|
78
|
+
def render(self, app):
|
|
79
|
+
padding = int(self.style.get('padding', 0))
|
|
80
|
+
border_width = 1 if 'border' in self.style else 0
|
|
81
|
+
|
|
82
|
+
min_y = self.y + padding + border_width + 1
|
|
83
|
+
max_y = min_y + max(0, self.height - 2*(padding + border_width))
|
|
84
|
+
min_x = self.x + padding + border_width + 1
|
|
85
|
+
max_x = min_x + max(0, self.width - 2*(padding + border_width))
|
|
86
|
+
|
|
87
|
+
prev_clip = getattr(app, 'clip_rect', None)
|
|
88
|
+
# We need to intersect with existing clip rect if any (skip for simplicity for now, assume 1 scrollview depth)
|
|
89
|
+
app.clip_rect = (min_y, max_y, min_x, max_x)
|
|
90
|
+
|
|
91
|
+
self.apply_style(app)
|
|
92
|
+
|
|
93
|
+
if 'background' in self.style:
|
|
94
|
+
for r in range(self.height):
|
|
95
|
+
app.draw_text(self.x + 1, self.y + r + 1, " " * self.width, apply_clip=False)
|
|
96
|
+
self.apply_style(app)
|
|
97
|
+
|
|
98
|
+
if self.style.get('border', 'none') != 'none':
|
|
99
|
+
self._draw_border(app)
|
|
100
|
+
|
|
101
|
+
self._draw_scrollbar(app)
|
|
102
|
+
|
|
103
|
+
for child in self.children:
|
|
104
|
+
child.render(app)
|
|
105
|
+
self.apply_style(app)
|
|
106
|
+
|
|
107
|
+
app.clip_rect = prev_clip
|
|
108
|
+
app.write(RESET)
|
|
109
|
+
|
|
110
|
+
def _draw_scrollbar(self, app):
|
|
111
|
+
inner_height = max(0, self.height - 2)
|
|
112
|
+
if self.max_scroll > 0 and inner_height > 0:
|
|
113
|
+
thumb_size = max(1, int(inner_height * (inner_height / (self.content_height or 1))))
|
|
114
|
+
thumb_pos = int((self.scroll_y / self.max_scroll) * (inner_height - thumb_size))
|
|
115
|
+
|
|
116
|
+
for i in range(inner_height):
|
|
117
|
+
char = '█' if thumb_pos <= i < thumb_pos + thumb_size else '▒'
|
|
118
|
+
app.draw_text(self.x + self.width - 1, self.y + 2 + i, char, apply_clip=False)
|
|
119
|
+
|
|
120
|
+
def handle_input(self, event: KeyEvent) -> bool:
|
|
121
|
+
# Check kids first
|
|
122
|
+
if super().handle_input(event):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
# Scroll up and down on arrow keys if we have standard keys mapping
|
|
126
|
+
if event.key == '\x1b[A': # Up Arrow
|
|
127
|
+
self.scroll_y = max(0, self.scroll_y - 1)
|
|
128
|
+
return True
|
|
129
|
+
elif event.key == '\x1b[B': # Down arrow
|
|
130
|
+
self.scroll_y = min(self.max_scroll, self.scroll_y + 1)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def handle_wheel(self, event: MouseEvent, app) -> bool:
|
|
136
|
+
term_x = event.x - 1
|
|
137
|
+
term_y = event.y - 1
|
|
138
|
+
|
|
139
|
+
if self.x <= term_x < self.x + self.width and self.y <= term_y < self.y + self.height:
|
|
140
|
+
if event.button == 64:
|
|
141
|
+
self.scroll_y = max(0, self.scroll_y - 1)
|
|
142
|
+
if app._running: app._render_cycle()
|
|
143
|
+
return True
|
|
144
|
+
elif event.button == 65:
|
|
145
|
+
self.scroll_y = min(self.max_scroll, self.scroll_y + 1)
|
|
146
|
+
if app._running: app._render_cycle()
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
class Color(Enum):
|
|
4
|
+
BLACK = 30
|
|
5
|
+
RED = 31
|
|
6
|
+
GREEN = 32
|
|
7
|
+
YELLOW = 33
|
|
8
|
+
BLUE = 34
|
|
9
|
+
MAGENTA = 35
|
|
10
|
+
CYAN = 36
|
|
11
|
+
WHITE = 37
|
|
12
|
+
DEFAULT = 39
|
|
13
|
+
|
|
14
|
+
class BackColor(Enum):
|
|
15
|
+
BLACK = 40
|
|
16
|
+
RED = 41
|
|
17
|
+
GREEN = 42
|
|
18
|
+
YELLOW = 43
|
|
19
|
+
BLUE = 44
|
|
20
|
+
MAGENTA = 45
|
|
21
|
+
CYAN = 46
|
|
22
|
+
WHITE = 47
|
|
23
|
+
DEFAULT = 49
|
|
24
|
+
|
|
25
|
+
ESC = '\x1b['
|
|
26
|
+
CLEAR_SCREEN = f"{ESC}2J"
|
|
27
|
+
ALT_SCREEN_ON = f"{ESC}?1049h"
|
|
28
|
+
ALT_SCREEN_OFF = f"{ESC}?1049l"
|
|
29
|
+
HIDE_CURSOR = f"{ESC}?25l"
|
|
30
|
+
SHOW_CURSOR = f"{ESC}?25h"
|
|
31
|
+
MOUSE_ON = f"{ESC}?1000h{ESC}?1006h"
|
|
32
|
+
MOUSE_OFF = f"{ESC}?1006l{ESC}?1000l"
|
|
33
|
+
|
|
34
|
+
# Text Styling
|
|
35
|
+
BOLD = f"{ESC}1m"
|
|
36
|
+
FAINT = f"{ESC}2m"
|
|
37
|
+
ITALIC = f"{ESC}3m"
|
|
38
|
+
UNDERLINE = f"{ESC}4m"
|
|
39
|
+
|
|
40
|
+
def move_cursor(x: int, y: int) -> str:
|
|
41
|
+
"""Move cursor to 1-based x, y position."""
|
|
42
|
+
return f"{ESC}{y};{x}H"
|
|
43
|
+
|
|
44
|
+
def color(fg: Color = Color.DEFAULT, bg: BackColor = BackColor.DEFAULT) -> str:
|
|
45
|
+
"""Set foreground and background color."""
|
|
46
|
+
return f"{ESC}{fg.value};{bg.value}m"
|
|
47
|
+
|
|
48
|
+
RESET = f"{ESC}0m"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import select
|
|
3
|
+
import signal
|
|
4
|
+
import re
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .terminal import RawTerminal, get_terminal_size
|
|
8
|
+
from .ansi import CLEAR_SCREEN, move_cursor, RESET
|
|
9
|
+
from .events import KeyEvent, ResizeEvent, MouseEvent
|
|
10
|
+
from ..style.css import parse_css
|
|
11
|
+
|
|
12
|
+
class App:
|
|
13
|
+
"""
|
|
14
|
+
The main application context and event loop.
|
|
15
|
+
Provides a base class for rendering and handling events.
|
|
16
|
+
"""
|
|
17
|
+
css = ""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._running = False
|
|
21
|
+
self._last_size = get_terminal_size()
|
|
22
|
+
|
|
23
|
+
# Buffer to accumulate output before flushing (double buffer concept)
|
|
24
|
+
self._output_buffer = []
|
|
25
|
+
self._input_buffer = ""
|
|
26
|
+
|
|
27
|
+
# DOM Root & Focus State
|
|
28
|
+
self.root = None
|
|
29
|
+
self.stylesheet = parse_css(self.css)
|
|
30
|
+
self.focused_widget = None
|
|
31
|
+
self.focused_id = None
|
|
32
|
+
|
|
33
|
+
def run(self):
|
|
34
|
+
"""Starts the event loop."""
|
|
35
|
+
self._running = True
|
|
36
|
+
|
|
37
|
+
# Attach to SIGWINCH for resize events in Unix
|
|
38
|
+
try:
|
|
39
|
+
signal.signal(signal.SIGWINCH, self._handle_sigwinch)
|
|
40
|
+
except AttributeError:
|
|
41
|
+
# SIGWINCH is not available on all systems (e.g. Windows)
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
with RawTerminal():
|
|
45
|
+
self._render_cycle()
|
|
46
|
+
|
|
47
|
+
while self._running:
|
|
48
|
+
# Wait for input (0.05s timeout for 20 FPS refresh rate max)
|
|
49
|
+
dr, _, _ = select.select([sys.stdin], [], [], 0.05)
|
|
50
|
+
|
|
51
|
+
if dr:
|
|
52
|
+
# Read all available bytes
|
|
53
|
+
try:
|
|
54
|
+
data = os.read(sys.stdin.fileno(), 1024).decode('utf-8', errors='replace')
|
|
55
|
+
except BlockingIOError:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if not data:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Extract all SGR Mouse Events iteratively
|
|
62
|
+
mouse_pattern = r'\x1b\[<(\d+);(\d+);(\d+)([mM])'
|
|
63
|
+
for match in re.finditer(mouse_pattern, data):
|
|
64
|
+
btn, x, y, state = match.groups()
|
|
65
|
+
is_pressed = (state == 'M')
|
|
66
|
+
mouse_evt = MouseEvent(x=int(x), y=int(y), button=int(btn), pressed=is_pressed)
|
|
67
|
+
self.handle_mouse(mouse_evt)
|
|
68
|
+
|
|
69
|
+
# Remove parsed mouse events
|
|
70
|
+
data = re.sub(mouse_pattern, '', data)
|
|
71
|
+
|
|
72
|
+
# Quick checks for remaining inputs
|
|
73
|
+
if '\x03' in data or '\x04' in data:
|
|
74
|
+
self.exit()
|
|
75
|
+
|
|
76
|
+
# Split remaining ANSI escape codes or parse them.
|
|
77
|
+
# For simplicity, if we see an escape sequence, treat the block roughly.
|
|
78
|
+
# Otherwise handle pure characters
|
|
79
|
+
i = 0
|
|
80
|
+
while i < len(data):
|
|
81
|
+
if data[i] == '\x1b':
|
|
82
|
+
# Probably an escape sequence (like arrows)
|
|
83
|
+
# Just capture roughly until a letter
|
|
84
|
+
end = i + 1
|
|
85
|
+
while end < len(data) and not data[end].isalpha() and data[end] != '~':
|
|
86
|
+
end += 1
|
|
87
|
+
if end < len(data): end += 1
|
|
88
|
+
|
|
89
|
+
sub = data[i:end]
|
|
90
|
+
self.handle_input(KeyEvent(key=sub, is_printable=False))
|
|
91
|
+
i = end
|
|
92
|
+
else:
|
|
93
|
+
char = data[i]
|
|
94
|
+
# Only handle printable or basic backspaces
|
|
95
|
+
if char in ('\x08', '\x7f', '\r', '\n', '\t') or char.isprintable():
|
|
96
|
+
# In raw mode, we might receive \r for enter.
|
|
97
|
+
is_print = char.isprintable()
|
|
98
|
+
if char in ('\r', '\n', '\t'):
|
|
99
|
+
is_print = False
|
|
100
|
+
self.handle_input(KeyEvent(key=char, is_printable=is_print))
|
|
101
|
+
i += 1
|
|
102
|
+
|
|
103
|
+
# Request a re-render after an input if app is still running
|
|
104
|
+
if self._running:
|
|
105
|
+
self._render_cycle()
|
|
106
|
+
|
|
107
|
+
# Optionally check if size changed periodically if SIGWINCH failed
|
|
108
|
+
current_size = get_terminal_size()
|
|
109
|
+
if current_size != self._last_size:
|
|
110
|
+
self._last_size = current_size
|
|
111
|
+
self.handle_resize(ResizeEvent(*current_size))
|
|
112
|
+
self._render_cycle()
|
|
113
|
+
|
|
114
|
+
def exit(self):
|
|
115
|
+
"""Signals the app to break out of the event loop."""
|
|
116
|
+
self._running = False
|
|
117
|
+
|
|
118
|
+
def write(self, text: str):
|
|
119
|
+
"""Appends output to the render buffer."""
|
|
120
|
+
self._output_buffer.append(text)
|
|
121
|
+
|
|
122
|
+
def draw_text(self, x: int, y: int, text: str, apply_clip=True):
|
|
123
|
+
"""Helper to draw text with optional vertical clipping."""
|
|
124
|
+
if apply_clip and hasattr(self, 'clip_rect') and self.clip_rect:
|
|
125
|
+
min_y, max_y, min_x, max_x = self.clip_rect
|
|
126
|
+
if y < min_y or y >= max_y:
|
|
127
|
+
return
|
|
128
|
+
# horizontal clipping
|
|
129
|
+
if x < min_x:
|
|
130
|
+
text = text[min_x - x:]
|
|
131
|
+
x = min_x
|
|
132
|
+
if x + len(text) > max_x:
|
|
133
|
+
text = text[:max_x - x]
|
|
134
|
+
if not text:
|
|
135
|
+
return
|
|
136
|
+
self.write(move_cursor(x, y))
|
|
137
|
+
self.write(text)
|
|
138
|
+
|
|
139
|
+
def flush(self):
|
|
140
|
+
"""Writes the entire render buffer to the stdout in one go to prevent flicker."""
|
|
141
|
+
if self._output_buffer:
|
|
142
|
+
sys.stdout.write("".join(self._output_buffer))
|
|
143
|
+
sys.stdout.flush()
|
|
144
|
+
self._output_buffer.clear()
|
|
145
|
+
|
|
146
|
+
def _render_cycle(self):
|
|
147
|
+
"""Internal render pass to orchestrate drawing."""
|
|
148
|
+
self.write(CLEAR_SCREEN)
|
|
149
|
+
self.write(move_cursor(1, 1))
|
|
150
|
+
|
|
151
|
+
# Call user's render method
|
|
152
|
+
self.render()
|
|
153
|
+
|
|
154
|
+
self.write(RESET)
|
|
155
|
+
self.flush()
|
|
156
|
+
|
|
157
|
+
def _handle_sigwinch(self, signum, frame):
|
|
158
|
+
"""Unix signal handler for terminal resize."""
|
|
159
|
+
size = get_terminal_size()
|
|
160
|
+
self._last_size = size
|
|
161
|
+
self.handle_resize(ResizeEvent(width=size[0], height=size[1]))
|
|
162
|
+
if self._running:
|
|
163
|
+
self._render_cycle()
|
|
164
|
+
|
|
165
|
+
# --- Methods to be overridden by subclasses (User API) ---
|
|
166
|
+
|
|
167
|
+
def handle_input(self, event: KeyEvent):
|
|
168
|
+
"""Triggered when a key is pressed."""
|
|
169
|
+
# By default, press 'q' to quit. Subclasses can override.
|
|
170
|
+
if event.key == 'q':
|
|
171
|
+
self.exit()
|
|
172
|
+
|
|
173
|
+
# Prioritize focused widget, else try routing through the DOM
|
|
174
|
+
consumed = False
|
|
175
|
+
if self.focused_widget:
|
|
176
|
+
consumed = self.focused_widget.handle_input(event)
|
|
177
|
+
|
|
178
|
+
if not consumed and self.root:
|
|
179
|
+
self.root.handle_input(event)
|
|
180
|
+
|
|
181
|
+
def handle_mouse(self, event: MouseEvent):
|
|
182
|
+
"""Triggered on mouse interaction."""
|
|
183
|
+
if self.root:
|
|
184
|
+
self.root.handle_mouse(event, self)
|
|
185
|
+
|
|
186
|
+
def handle_resize(self, event: ResizeEvent):
|
|
187
|
+
"""Triggered when the terminal window changes size."""
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def build_ui(self):
|
|
191
|
+
"""Override this to return the root widget tree."""
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def render(self):
|
|
195
|
+
"""
|
|
196
|
+
The main rendering function.
|
|
197
|
+
Rebuilds styling and layout, then renders the full Widget tree.
|
|
198
|
+
"""
|
|
199
|
+
if not self.root:
|
|
200
|
+
self.root = self.build_ui()
|
|
201
|
+
|
|
202
|
+
# Restore focus after a UI rebuild if an element ID was previously focused
|
|
203
|
+
if self.focused_id:
|
|
204
|
+
def restore_focus(node):
|
|
205
|
+
if node.id == self.focused_id:
|
|
206
|
+
node.focused = True
|
|
207
|
+
self.focused_widget = node
|
|
208
|
+
for c in node.children:
|
|
209
|
+
restore_focus(c)
|
|
210
|
+
self.focused_widget = None
|
|
211
|
+
restore_focus(self.root)
|
|
212
|
+
|
|
213
|
+
if self.root:
|
|
214
|
+
# 1. Resolve CSS
|
|
215
|
+
self.root.resolve_styles(self.stylesheet)
|
|
216
|
+
# 2. Assign root X/Y
|
|
217
|
+
self.root.x = 0
|
|
218
|
+
self.root.y = 0
|
|
219
|
+
# 3. Calculate Layout
|
|
220
|
+
w, h = self._last_size
|
|
221
|
+
self.root.calculate_layout(w, h)
|
|
222
|
+
# 4. Render Layout
|
|
223
|
+
self.root.render(self)
|
|
224
|
+
else:
|
|
225
|
+
self.write(f"Casca UI - Press 'q' to exit.\\n Terminal size: {self._last_size}")
|
|
226
|
+
|
|
227
|
+
def run_app(widget, css=""):
|
|
228
|
+
"""A convenience wrapper to run a single widget without subclassing App."""
|
|
229
|
+
class WrapperApp(App):
|
|
230
|
+
def __init__(self):
|
|
231
|
+
super().__init__()
|
|
232
|
+
self.css = css
|
|
233
|
+
def build_ui(self):
|
|
234
|
+
return widget
|
|
235
|
+
|
|
236
|
+
WrapperApp().run()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class KeyEvent:
|
|
5
|
+
key: str
|
|
6
|
+
is_printable: bool
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ResizeEvent:
|
|
10
|
+
width: int
|
|
11
|
+
height: int
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MouseEvent:
|
|
15
|
+
x: int
|
|
16
|
+
y: int
|
|
17
|
+
button: int
|
|
18
|
+
pressed: bool
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tty
|
|
4
|
+
import termios
|
|
5
|
+
|
|
6
|
+
from .ansi import ALT_SCREEN_ON, ALT_SCREEN_OFF, HIDE_CURSOR, SHOW_CURSOR, MOUSE_ON, MOUSE_OFF
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RawTerminal:
|
|
10
|
+
"""
|
|
11
|
+
Context manager to safely put the terminal into raw mode,
|
|
12
|
+
switch to the alternate screen buffer, and hide the cursor.
|
|
13
|
+
"""
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.fd = sys.stdin.fileno()
|
|
16
|
+
self.old_settings = None
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
# Save old terminal settings
|
|
20
|
+
self.old_settings = termios.tcgetattr(self.fd)
|
|
21
|
+
|
|
22
|
+
# Set raw mode to read characters immediately
|
|
23
|
+
tty.setraw(self.fd)
|
|
24
|
+
|
|
25
|
+
# Switch to alternate screen, hide cursor, enable mouse tracking
|
|
26
|
+
sys.stdout.write(ALT_SCREEN_ON)
|
|
27
|
+
sys.stdout.write(HIDE_CURSOR)
|
|
28
|
+
sys.stdout.write(MOUSE_ON)
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
33
|
+
# Restore normal screen, show cursor, disable mouse
|
|
34
|
+
sys.stdout.write(MOUSE_OFF)
|
|
35
|
+
sys.stdout.write(ALT_SCREEN_OFF)
|
|
36
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
37
|
+
sys.stdout.flush()
|
|
38
|
+
|
|
39
|
+
# Restore terminal settings
|
|
40
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old_settings)
|
|
41
|
+
|
|
42
|
+
def get_terminal_size() -> tuple[int, int]:
|
|
43
|
+
"""Returns (width, height) of the current terminal."""
|
|
44
|
+
try:
|
|
45
|
+
size = os.get_terminal_size()
|
|
46
|
+
return size.columns, size.lines
|
|
47
|
+
except OSError:
|
|
48
|
+
return 80, 24
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from typing import Optional, List
|
|
2
|
+
from ..core.ansi import Color, BackColor, color, move_cursor, BOLD, FAINT, ITALIC, UNDERLINE, RESET
|
|
3
|
+
from ..core.events import KeyEvent, MouseEvent
|
|
4
|
+
|
|
5
|
+
class Widget:
|
|
6
|
+
"""The base class for all UI elements (DOM Node)."""
|
|
7
|
+
|
|
8
|
+
tag = 'widget'
|
|
9
|
+
|
|
10
|
+
def __init__(self, *children, id: str = "", classes: str = "", **props):
|
|
11
|
+
self.children: List['Widget'] = list(children)
|
|
12
|
+
self.id = id
|
|
13
|
+
self.classes = classes.split() if classes else []
|
|
14
|
+
self.props = props
|
|
15
|
+
|
|
16
|
+
# Layout properties (calculated)
|
|
17
|
+
self.x = 0
|
|
18
|
+
self.y = 0
|
|
19
|
+
self.width = 0
|
|
20
|
+
self.height = 0
|
|
21
|
+
self.content_width = 0
|
|
22
|
+
self.content_height = 0
|
|
23
|
+
|
|
24
|
+
# Resolved styles (from CSS)
|
|
25
|
+
self.style = {}
|
|
26
|
+
|
|
27
|
+
# State
|
|
28
|
+
self.focused = False
|
|
29
|
+
|
|
30
|
+
def resolve_styles(self, stylesheet: dict):
|
|
31
|
+
from ..style.css import get_style_for_node
|
|
32
|
+
self.style = get_style_for_node(self.id, self.classes, self.tag, stylesheet)
|
|
33
|
+
|
|
34
|
+
# Recursively resolve styles for children
|
|
35
|
+
for child in self.children:
|
|
36
|
+
child.resolve_styles(stylesheet)
|
|
37
|
+
|
|
38
|
+
def calculate_layout(self, available_width: int, available_height: int) -> tuple[int, int]:
|
|
39
|
+
"""Base layout logic Flow (stacking). Returns tuple of (width_used, height_used)."""
|
|
40
|
+
|
|
41
|
+
# Apply padding and border calculations
|
|
42
|
+
padding = int(self.style.get('padding', 0))
|
|
43
|
+
margin = int(self.style.get('margin', 0))
|
|
44
|
+
border_width = 1 if 'border' in self.style else 0
|
|
45
|
+
|
|
46
|
+
# Width priority: Direct property -> Inline style -> Available parent width
|
|
47
|
+
self.width = self.props.get('width', int(self.style.get('width', available_width)))
|
|
48
|
+
|
|
49
|
+
# Content layout logic (stack children vertically by default)
|
|
50
|
+
current_y = padding + border_width
|
|
51
|
+
max_child_width = 0
|
|
52
|
+
|
|
53
|
+
for child in self.children:
|
|
54
|
+
child.x = self.x + padding + border_width
|
|
55
|
+
child.y = self.y + current_y
|
|
56
|
+
|
|
57
|
+
# The children can occupy the inner space
|
|
58
|
+
inner_avail_width = max(0, self.width - 2*(padding + border_width))
|
|
59
|
+
|
|
60
|
+
child_w, child_h = child.calculate_layout(inner_avail_width, available_height - current_y)
|
|
61
|
+
current_y += child_h
|
|
62
|
+
max_child_width = max(max_child_width, child_w)
|
|
63
|
+
|
|
64
|
+
self.content_width = max_child_width
|
|
65
|
+
self.content_height = current_y - (padding + border_width)
|
|
66
|
+
|
|
67
|
+
# Total height used by this widget including padding, border and margin
|
|
68
|
+
min_height = padding * 2 + border_width * 2
|
|
69
|
+
|
|
70
|
+
# If height is strictly set
|
|
71
|
+
set_height = self.props.get('height') or self.style.get('height')
|
|
72
|
+
if set_height:
|
|
73
|
+
self.height = int(set_height)
|
|
74
|
+
else:
|
|
75
|
+
self.height = self.content_height + min_height
|
|
76
|
+
|
|
77
|
+
return self.width + margin * 2, self.height + margin * 2
|
|
78
|
+
|
|
79
|
+
def apply_style(self, app):
|
|
80
|
+
"""Applies the current widget's computed style and background to the terminal output."""
|
|
81
|
+
app.write(RESET)
|
|
82
|
+
border_style = self.style.get('border', 'none')
|
|
83
|
+
fg_color = self.style.get('color', 'DEFAULT').upper()
|
|
84
|
+
bg_color = self.style.get('background', 'DEFAULT').upper()
|
|
85
|
+
|
|
86
|
+
fg = getattr(Color, fg_color, Color.DEFAULT)
|
|
87
|
+
bg = getattr(BackColor, bg_color, BackColor.DEFAULT)
|
|
88
|
+
app.write(color(fg, bg))
|
|
89
|
+
|
|
90
|
+
# Apply text styling
|
|
91
|
+
if self.style.get('font-weight') == 'bold': app.write(BOLD)
|
|
92
|
+
if self.style.get('font-style') == 'italic': app.write(ITALIC)
|
|
93
|
+
if self.style.get('text-decoration') == 'underline': app.write(UNDERLINE)
|
|
94
|
+
if 'opacity' in self.style and float(self.style['opacity']) < 1.0: app.write(FAINT)
|
|
95
|
+
|
|
96
|
+
def render(self, app):
|
|
97
|
+
"""Draws the widget to the app render buffer."""
|
|
98
|
+
self.apply_style(app)
|
|
99
|
+
|
|
100
|
+
# Draw background fill if applicable
|
|
101
|
+
if 'background' in self.style:
|
|
102
|
+
for r in range(self.height):
|
|
103
|
+
app.draw_text(self.x + 1, self.y + r + 1, " " * self.width)
|
|
104
|
+
self.apply_style(app)
|
|
105
|
+
|
|
106
|
+
# Draw border
|
|
107
|
+
if self.style.get('border', 'none') != 'none':
|
|
108
|
+
self._draw_border(app)
|
|
109
|
+
|
|
110
|
+
# Draw own content
|
|
111
|
+
self.render_content(app)
|
|
112
|
+
|
|
113
|
+
# Draw children
|
|
114
|
+
for child in(self.children):
|
|
115
|
+
child.render(app)
|
|
116
|
+
# Reattach own style so subsequent children/parents don't get reset
|
|
117
|
+
self.apply_style(app)
|
|
118
|
+
|
|
119
|
+
app.write(RESET)
|
|
120
|
+
|
|
121
|
+
def render_content(self, app):
|
|
122
|
+
"""Override to implement specific widget text / drawing logic."""
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _draw_border(self, app):
|
|
126
|
+
border_char = self.style.get('border-char', '+') if self.style.get('border') == 'ascii' else '█'
|
|
127
|
+
# Top and bottom
|
|
128
|
+
for r in (0, self.height - 1):
|
|
129
|
+
if r >= 0 and self.width > 0:
|
|
130
|
+
app.draw_text(self.x + 1, self.y + r + 1, border_char * self.width)
|
|
131
|
+
|
|
132
|
+
# Sides
|
|
133
|
+
for r in range(1, self.height - 1):
|
|
134
|
+
if self.width > 0:
|
|
135
|
+
app.draw_text(self.x + 1, self.y + r + 1, border_char)
|
|
136
|
+
if self.width > 1:
|
|
137
|
+
app.draw_text(self.x + self.width, self.y + r + 1, border_char)
|
|
138
|
+
|
|
139
|
+
def handle_input(self, event: KeyEvent) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Returns True if the event was consumed, preventing bubbling.
|
|
142
|
+
Passes input down the tree.
|
|
143
|
+
"""
|
|
144
|
+
for child in reversed(self.children):
|
|
145
|
+
if child.handle_input(event):
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
def handle_mouse(self, event: MouseEvent, app) -> bool:
|
|
150
|
+
"""Passes mouse events down if they fall within bounds."""
|
|
151
|
+
# Terminal coordinates are typically 1-based, and our widget coordinates (x,y)
|
|
152
|
+
# may be 0-based logically, but rendered 1-based.
|
|
153
|
+
# Actually our calculate_layout produces 0-based x, y. And move_cursor adds 1.
|
|
154
|
+
# Let's align on 0-based checking. The event has 1-based x,y.
|
|
155
|
+
term_x = event.x - 1
|
|
156
|
+
term_y = event.y - 1
|
|
157
|
+
|
|
158
|
+
if self.x <= term_x < self.x + self.width and self.y <= term_y < self.y + self.height:
|
|
159
|
+
# Propagate to children first (highest z-index effectively)
|
|
160
|
+
for child in reversed(self.children):
|
|
161
|
+
if child.handle_mouse(event, app):
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
if event.pressed and self.tag in ('input', 'button', 'checkbox'):
|
|
165
|
+
if app.focused_widget and app.focused_widget != self:
|
|
166
|
+
app.focused_widget.focused = False
|
|
167
|
+
app.focused_widget = self
|
|
168
|
+
self.focused = True
|
|
169
|
+
if self.id:
|
|
170
|
+
app.focused_id = self.id
|
|
171
|
+
|
|
172
|
+
# If kids didn't handle it, try handling it ourselves
|
|
173
|
+
handled = self.on_mouse(event) or (event.pressed and self.tag in ('input', 'button', 'checkbox'))
|
|
174
|
+
|
|
175
|
+
# Special case for scroll wheel in bounds but not handled
|
|
176
|
+
if not handled and event.button in (64, 65):
|
|
177
|
+
return getattr(self, 'handle_wheel', lambda e, a: False)(event, app)
|
|
178
|
+
|
|
179
|
+
return handled
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def on_mouse(self, event: MouseEvent) -> bool:
|
|
183
|
+
"""Override to implement specific widget mouse logic."""
|
|
184
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
|
|
4
|
+
def parse_css(css_string: str) -> Dict[str, Dict[str, str]]:
|
|
5
|
+
"""
|
|
6
|
+
Extremely lightweight CSS parser.
|
|
7
|
+
Supports basic classes (.cls), IDs (#id), and Element (tag) selectors.
|
|
8
|
+
Returns: { '.btn': {'color': 'red', 'padding': '1'}, ... }
|
|
9
|
+
"""
|
|
10
|
+
stylesheet = {}
|
|
11
|
+
|
|
12
|
+
# Remove comments
|
|
13
|
+
css_string = re.sub(r'/\*.*?\*/', '', css_string, flags=re.DOTALL)
|
|
14
|
+
|
|
15
|
+
# Find all blocks: selector { rules }
|
|
16
|
+
blocks = re.findall(r'([^{]+)\{([^}]+)\}', css_string)
|
|
17
|
+
|
|
18
|
+
for selector_group, rules_text in blocks:
|
|
19
|
+
selectors = [s.strip() for s in selector_group.split(',')]
|
|
20
|
+
|
|
21
|
+
rules = {}
|
|
22
|
+
for rule in rules_text.split(';'):
|
|
23
|
+
rule = rule.strip()
|
|
24
|
+
if not rule:
|
|
25
|
+
continue
|
|
26
|
+
if ':' in rule:
|
|
27
|
+
key, value = rule.split(':', 1)
|
|
28
|
+
rules[key.strip()] = value.strip()
|
|
29
|
+
|
|
30
|
+
for selector in selectors:
|
|
31
|
+
if selector not in stylesheet:
|
|
32
|
+
stylesheet[selector] = {}
|
|
33
|
+
stylesheet[selector].update(rules)
|
|
34
|
+
|
|
35
|
+
return stylesheet
|
|
36
|
+
|
|
37
|
+
def get_style_for_node(node_id: str, classes: list[str], tag: str, stylesheet: dict) -> dict:
|
|
38
|
+
"""Resolve styles for a given node in order of specificity: tag -> class -> id"""
|
|
39
|
+
style = {}
|
|
40
|
+
|
|
41
|
+
# 1. Tag
|
|
42
|
+
if tag in stylesheet:
|
|
43
|
+
style.update(stylesheet[tag])
|
|
44
|
+
|
|
45
|
+
# 2. Classes
|
|
46
|
+
for cls in classes:
|
|
47
|
+
cls_sel = f".{cls}"
|
|
48
|
+
if cls_sel in stylesheet:
|
|
49
|
+
style.update(stylesheet[cls_sel])
|
|
50
|
+
|
|
51
|
+
# 3. ID
|
|
52
|
+
if node_id:
|
|
53
|
+
id_sel = f"#{node_id}"
|
|
54
|
+
if id_sel in stylesheet:
|
|
55
|
+
style.update(stylesheet[id_sel])
|
|
56
|
+
|
|
57
|
+
return style
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: casca
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native Python CLI UI library with CSS-like styling
|
|
5
|
+
Author-email: Abdallah <abdalla.zain2004@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: tui,cli,terminal,ui,css,python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: chatbot
|
|
18
|
+
Requires-Dist: groq>=0.20.0; extra == "chatbot"
|
|
19
|
+
Provides-Extra: docs
|
|
20
|
+
Requires-Dist: mkdocs>=1.6.0; extra == "docs"
|
|
21
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == "docs"
|
|
22
|
+
Requires-Dist: pymdown-extensions>=10.8.0; extra == "docs"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Casca
|
|
26
|
+
|
|
27
|
+
Casca is a lightweight, native Python TUI library with a DOM-like widget tree and CSS-style theming.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Zero core dependencies (standard library runtime)
|
|
32
|
+
- Declarative widgets (`Container`, `Label`, `Input`, `Button`, `Card`, `Header`, `ScrollView`)
|
|
33
|
+
- CSS-like style parsing and cascade
|
|
34
|
+
- Keyboard and mouse event handling in raw terminal mode
|
|
35
|
+
- Real examples including login screen, dashboard, and Groq chatbot
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from casca import Container, Label, run_app
|
|
41
|
+
|
|
42
|
+
CSS = """
|
|
43
|
+
#root {
|
|
44
|
+
padding: 1;
|
|
45
|
+
border: ascii;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ui_tree = Container(
|
|
52
|
+
Label("Hello from Casca"),
|
|
53
|
+
id="root",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
run_app(ui_tree, css=CSS)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Run Use Cases
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python3 -m use_cases.login_screen
|
|
64
|
+
python3 -m use_cases.dashboard
|
|
65
|
+
python3 -m use_cases.chatbot
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`use_cases.chatbot` requires `groq` and a `GROQ_API_KEY`.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## Project Layout
|
|
72
|
+
|
|
73
|
+
- `casca/`: core library
|
|
74
|
+
- `use_cases/`: real apps built with Casca
|
|
75
|
+
- `examples/`: smaller demos
|
|
76
|
+
- `docs/`: MkDocs content
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
casca/__init__.py
|
|
5
|
+
casca.egg-info/PKG-INFO
|
|
6
|
+
casca.egg-info/SOURCES.txt
|
|
7
|
+
casca.egg-info/dependency_links.txt
|
|
8
|
+
casca.egg-info/requires.txt
|
|
9
|
+
casca.egg-info/top_level.txt
|
|
10
|
+
casca/components/__init__.py
|
|
11
|
+
casca/components/base.py
|
|
12
|
+
casca/components/forms.py
|
|
13
|
+
casca/components/panels.py
|
|
14
|
+
casca/components/scroll.py
|
|
15
|
+
casca/core/__init__.py
|
|
16
|
+
casca/core/ansi.py
|
|
17
|
+
casca/core/app.py
|
|
18
|
+
casca/core/events.py
|
|
19
|
+
casca/core/terminal.py
|
|
20
|
+
casca/dom/__init__.py
|
|
21
|
+
casca/dom/node.py
|
|
22
|
+
casca/style/__init__.py
|
|
23
|
+
casca/style/css.py
|
|
24
|
+
tests/test_imports.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
casca
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "casca"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Native Python CLI UI library with CSS-like styling"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Abdallah", email = "abdalla.zain2004@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["tui", "cli", "terminal", "ui", "css", "python"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Topic :: Software Development :: User Interfaces"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
chatbot = ["groq>=0.20.0"]
|
|
27
|
+
docs = [
|
|
28
|
+
"mkdocs>=1.6.0",
|
|
29
|
+
"mkdocs-material>=9.5.0",
|
|
30
|
+
"pymdown-extensions>=10.8.0"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["casca", "casca.core", "casca.dom", "casca.style", "casca.components"]
|
casca-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from casca import App, Container, Label, Button, Input, Checkbox, Card, Header
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_core_imports_exist():
|
|
5
|
+
assert App is not None
|
|
6
|
+
assert Container is not None
|
|
7
|
+
assert Label is not None
|
|
8
|
+
assert Button is not None
|
|
9
|
+
assert Input is not None
|
|
10
|
+
assert Checkbox is not None
|
|
11
|
+
assert Card is not None
|
|
12
|
+
assert Header is not None
|