espressoTUI 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.
- espresso/__init__.py +61 -0
- espresso/beans/__init__.py +304 -0
- espresso/beans/bar_chart.py +301 -0
- espresso/beans/codeviewer.py +349 -0
- espresso/beans/command_palette.py +398 -0
- espresso/beans/confetti.py +272 -0
- espresso/beans/datepicker.py +493 -0
- espresso/beans/detail_selector.py +191 -0
- espresso/beans/dialog.py +120 -0
- espresso/beans/diff_viewer.py +468 -0
- espresso/beans/filepicker.py +276 -0
- espresso/beans/form.py +294 -0
- espresso/beans/git_tree.py +366 -0
- espresso/beans/help.py +200 -0
- espresso/beans/image.py +329 -0
- espresso/beans/list.py +506 -0
- espresso/beans/markdown.py +259 -0
- espresso/beans/marquee.py +161 -0
- espresso/beans/metric.py +177 -0
- espresso/beans/navstack.py +161 -0
- espresso/beans/paginator.py +159 -0
- espresso/beans/pipeline_progress.py +313 -0
- espresso/beans/progress.py +59 -0
- espresso/beans/prompt.py +269 -0
- espresso/beans/quickfix.py +219 -0
- espresso/beans/slider.py +417 -0
- espresso/beans/sortable_list.py +305 -0
- espresso/beans/sparkline.py +285 -0
- espresso/beans/spinner.py +69 -0
- espresso/beans/splitter.py +329 -0
- espresso/beans/spring.py +284 -0
- espresso/beans/statusbar.py +161 -0
- espresso/beans/table.py +139 -0
- espresso/beans/tabs.py +125 -0
- espresso/beans/textarea.py +425 -0
- espresso/beans/textinput.py +138 -0
- espresso/beans/timer.py +277 -0
- espresso/beans/toast.py +118 -0
- espresso/beans/tree.py +160 -0
- espresso/beans/viewport.py +104 -0
- espresso/cli.py +219 -0
- espresso/core/__init__.py +45 -0
- espresso/core/keys.py +226 -0
- espresso/core/mouse.py +189 -0
- espresso/core/program.py +388 -0
- espresso/core/tea.py +187 -0
- espresso/core/terminal.py +178 -0
- espresso/crema/__init__.py +74 -0
- espresso/crema/border.py +87 -0
- espresso/crema/color.py +131 -0
- espresso/crema/flexbox.py +257 -0
- espresso/crema/gradient.py +196 -0
- espresso/crema/grid.py +174 -0
- espresso/crema/layout.py +130 -0
- espresso/crema/overlay.py +151 -0
- espresso/crema/style.py +464 -0
- espresso/crema/width.py +119 -0
- espresso/crema/wrap.py +346 -0
- espresso/py.typed +1 -0
- espressotui-1.0.0.dist-info/METADATA +315 -0
- espressotui-1.0.0.dist-info/RECORD +64 -0
- espressotui-1.0.0.dist-info/WHEEL +4 -0
- espressotui-1.0.0.dist-info/entry_points.txt +3 -0
- espressotui-1.0.0.dist-info/licenses/LICENSE +21 -0
espresso/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Espresso: A lightweight, declarative Elm Architecture (TEA) TUI framework for Python.
|
|
2
|
+
|
|
3
|
+
Inspired by Bubble Tea, Lip Gloss, and Bubbles.
|
|
4
|
+
|
|
5
|
+
- espresso: Core TEA framework, event loop, and terminal driver.
|
|
6
|
+
- espresso.crema: Rich styling, box model, colors, and layout engine.
|
|
7
|
+
- espresso.beans: Reusable UI components.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from espresso.core.keys import Key, KeyMsg
|
|
11
|
+
from espresso.core.mouse import MouseAction, MouseButton, MouseMsg
|
|
12
|
+
from espresso.core.program import Program
|
|
13
|
+
from espresso.core.tea import (
|
|
14
|
+
BatchMsg,
|
|
15
|
+
Cmd,
|
|
16
|
+
DisableMouseMsg,
|
|
17
|
+
EnableMouseMsg,
|
|
18
|
+
Model,
|
|
19
|
+
Msg,
|
|
20
|
+
QuitMsg,
|
|
21
|
+
TickMsg,
|
|
22
|
+
WindowSizeMsg,
|
|
23
|
+
batch,
|
|
24
|
+
disable_mouse,
|
|
25
|
+
enable_mouse,
|
|
26
|
+
quit_app,
|
|
27
|
+
sequence,
|
|
28
|
+
tick,
|
|
29
|
+
)
|
|
30
|
+
from espresso import beans, crema
|
|
31
|
+
|
|
32
|
+
__version__ = "1.0.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Model",
|
|
36
|
+
"Msg",
|
|
37
|
+
"Cmd",
|
|
38
|
+
"Program",
|
|
39
|
+
"Key",
|
|
40
|
+
"KeyMsg",
|
|
41
|
+
"MouseMsg",
|
|
42
|
+
"MouseButton",
|
|
43
|
+
"MouseAction",
|
|
44
|
+
"EnableMouseMsg",
|
|
45
|
+
"DisableMouseMsg",
|
|
46
|
+
"enable_mouse",
|
|
47
|
+
"disable_mouse",
|
|
48
|
+
"QuitMsg",
|
|
49
|
+
"WindowSizeMsg",
|
|
50
|
+
"BatchMsg",
|
|
51
|
+
"TickMsg",
|
|
52
|
+
"batch",
|
|
53
|
+
"sequence",
|
|
54
|
+
"tick",
|
|
55
|
+
"quit_app",
|
|
56
|
+
"crema",
|
|
57
|
+
"beans",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Beans: Standard library of reusable TUI components for Espresso.
|
|
2
|
+
|
|
3
|
+
Inspired by Bubbles.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from espresso.beans.bar_chart import (
|
|
7
|
+
BarChart,
|
|
8
|
+
BarChartSelectMsg,
|
|
9
|
+
BarItem,
|
|
10
|
+
BarOrientation,
|
|
11
|
+
)
|
|
12
|
+
from espresso.beans.codeviewer import (
|
|
13
|
+
CodeViewer,
|
|
14
|
+
SyntaxTheme,
|
|
15
|
+
THEME_DRACULA,
|
|
16
|
+
THEME_ESPRESSO,
|
|
17
|
+
THEME_MONOKAI,
|
|
18
|
+
highlight_code,
|
|
19
|
+
)
|
|
20
|
+
from espresso.beans.command_palette import (
|
|
21
|
+
CommandPalette,
|
|
22
|
+
CommandPaletteCloseMsg,
|
|
23
|
+
CommandPaletteSelectMsg,
|
|
24
|
+
PaletteItem,
|
|
25
|
+
)
|
|
26
|
+
from espresso.beans.git_tree import (
|
|
27
|
+
GitFileNode,
|
|
28
|
+
GitFileStatus,
|
|
29
|
+
GitTree,
|
|
30
|
+
GitTreeSelectMsg,
|
|
31
|
+
GitTreeToggleMsg,
|
|
32
|
+
)
|
|
33
|
+
from espresso.beans.datepicker import (
|
|
34
|
+
DateChangeMsg,
|
|
35
|
+
DatePicker,
|
|
36
|
+
DatePickerFocus,
|
|
37
|
+
DateSelectMsg,
|
|
38
|
+
)
|
|
39
|
+
from espresso.beans.detail_selector import DetailItem, DetailSelectMsg, DetailSelector
|
|
40
|
+
from espresso.beans.dialog import Dialog, DialogResultMsg
|
|
41
|
+
from espresso.beans.filepicker import FileEntry, FilePicker, FileSelectMsg, format_file_size
|
|
42
|
+
from espresso.beans.help import Help, KeyBinding, KeyMap
|
|
43
|
+
from espresso.beans.image import ImageViewer, RenderMode, parse_bmp, parse_ppm
|
|
44
|
+
from espresso.beans.list import List, ListItem, ListSelectMsg, PaginationMode
|
|
45
|
+
from espresso.beans.markdown import MarkdownViewer, render_markdown
|
|
46
|
+
from espresso.beans.metric import (
|
|
47
|
+
LayoutDirection,
|
|
48
|
+
Metric,
|
|
49
|
+
MetricGroup,
|
|
50
|
+
MetricLayout,
|
|
51
|
+
MetricTrend,
|
|
52
|
+
)
|
|
53
|
+
from espresso.beans.navstack import NavEntry, NavPopMsg, NavPushMsg, NavStack
|
|
54
|
+
from espresso.beans.paginator import Paginator, PaginatorType
|
|
55
|
+
from espresso.beans.pipeline_progress import (
|
|
56
|
+
PipelineCompleteMsg,
|
|
57
|
+
PipelineProgress,
|
|
58
|
+
PipelineStage,
|
|
59
|
+
StageCompleteMsg,
|
|
60
|
+
StageFailedMsg,
|
|
61
|
+
StageStartMsg,
|
|
62
|
+
StageStatus,
|
|
63
|
+
)
|
|
64
|
+
from espresso.beans.progress import Progress
|
|
65
|
+
from espresso.beans.prompt import (
|
|
66
|
+
ConfirmPrompt,
|
|
67
|
+
ConfirmSubmitMsg,
|
|
68
|
+
MultiSelectPrompt,
|
|
69
|
+
MultiSelectSubmitMsg,
|
|
70
|
+
SelectPrompt,
|
|
71
|
+
SelectSubmitMsg,
|
|
72
|
+
)
|
|
73
|
+
from espresso.beans.quickfix import QuickFix, QuickFixItem, QuickFixSelectMsg
|
|
74
|
+
from espresso.beans.spinner import (
|
|
75
|
+
COFFEE,
|
|
76
|
+
DOTS,
|
|
77
|
+
GLOBE,
|
|
78
|
+
LINE,
|
|
79
|
+
MOON,
|
|
80
|
+
POINTS,
|
|
81
|
+
PULSE,
|
|
82
|
+
Spinner,
|
|
83
|
+
SpinnerTickMsg,
|
|
84
|
+
)
|
|
85
|
+
from espresso.beans.confetti import (
|
|
86
|
+
Confetti,
|
|
87
|
+
ConfettiMode,
|
|
88
|
+
ConfettiTickMsg,
|
|
89
|
+
Particle,
|
|
90
|
+
)
|
|
91
|
+
from espresso.beans.diff_viewer import (
|
|
92
|
+
DiffHunk,
|
|
93
|
+
DiffLine,
|
|
94
|
+
DiffMode,
|
|
95
|
+
DiffViewer,
|
|
96
|
+
)
|
|
97
|
+
from espresso.beans.form import Form, FormField, FormSubmitMsg
|
|
98
|
+
from espresso.beans.marquee import Marquee, MarqueeMode, MarqueeTickMsg
|
|
99
|
+
from espresso.beans.slider import (
|
|
100
|
+
RangeSlider,
|
|
101
|
+
RangeSliderChangeMsg,
|
|
102
|
+
Slider,
|
|
103
|
+
SliderChangeMsg,
|
|
104
|
+
)
|
|
105
|
+
from espresso.beans.sortable_list import ItemReorderedMsg, SortableItem, SortableList
|
|
106
|
+
from espresso.beans.sparkline import Sparkline, SparklineMode, SparklineTickMsg
|
|
107
|
+
from espresso.beans.spring import Spring, SpringTickMsg, SpringValue
|
|
108
|
+
from espresso.beans.splitter import (
|
|
109
|
+
Splitter,
|
|
110
|
+
SplitterOrientation,
|
|
111
|
+
SplitterResizeMsg,
|
|
112
|
+
)
|
|
113
|
+
from espresso.beans.statusbar import StatusBar, StatusSection
|
|
114
|
+
from espresso.beans.table import Column, Table
|
|
115
|
+
from espresso.beans.tabs import TabChangeMsg, TabStyle, Tabs
|
|
116
|
+
from espresso.beans.textarea import TextArea
|
|
117
|
+
from espresso.beans.textinput import EchoMode, TextInput
|
|
118
|
+
from espresso.beans.timer import (
|
|
119
|
+
Stopwatch,
|
|
120
|
+
StopwatchTickMsg,
|
|
121
|
+
Timer,
|
|
122
|
+
TimerTickMsg,
|
|
123
|
+
TimerTimeoutMsg,
|
|
124
|
+
)
|
|
125
|
+
from espresso.beans.toast import ToastDismissMsg, ToastItem, ToastLevel, ToastManager
|
|
126
|
+
from espresso.beans.tree import Tree, TreeNode, TreeNodeSelectMsg
|
|
127
|
+
from espresso.beans.viewport import Viewport
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
# Spinner
|
|
131
|
+
"Spinner",
|
|
132
|
+
"SpinnerTickMsg",
|
|
133
|
+
"DOTS",
|
|
134
|
+
"LINE",
|
|
135
|
+
"PULSE",
|
|
136
|
+
"POINTS",
|
|
137
|
+
"MOON",
|
|
138
|
+
"GLOBE",
|
|
139
|
+
"COFFEE",
|
|
140
|
+
# TextInput
|
|
141
|
+
"TextInput",
|
|
142
|
+
"EchoMode",
|
|
143
|
+
# TextArea
|
|
144
|
+
"TextArea",
|
|
145
|
+
# Help & Keys
|
|
146
|
+
"Help",
|
|
147
|
+
"KeyBinding",
|
|
148
|
+
"KeyMap",
|
|
149
|
+
# Timer & Stopwatch
|
|
150
|
+
"Timer",
|
|
151
|
+
"TimerTickMsg",
|
|
152
|
+
"TimerTimeoutMsg",
|
|
153
|
+
"Stopwatch",
|
|
154
|
+
"StopwatchTickMsg",
|
|
155
|
+
# Progress
|
|
156
|
+
"Progress",
|
|
157
|
+
# Viewport
|
|
158
|
+
"Viewport",
|
|
159
|
+
# Table
|
|
160
|
+
"Table",
|
|
161
|
+
"Column",
|
|
162
|
+
# Paginator
|
|
163
|
+
"Paginator",
|
|
164
|
+
"PaginatorType",
|
|
165
|
+
# Dialog
|
|
166
|
+
"Dialog",
|
|
167
|
+
"DialogResultMsg",
|
|
168
|
+
# List
|
|
169
|
+
"List",
|
|
170
|
+
"ListItem",
|
|
171
|
+
"ListSelectMsg",
|
|
172
|
+
"PaginationMode",
|
|
173
|
+
# FilePicker
|
|
174
|
+
"FilePicker",
|
|
175
|
+
"FileEntry",
|
|
176
|
+
"FileSelectMsg",
|
|
177
|
+
"format_file_size",
|
|
178
|
+
# Prompts
|
|
179
|
+
"SelectPrompt",
|
|
180
|
+
"SelectSubmitMsg",
|
|
181
|
+
"MultiSelectPrompt",
|
|
182
|
+
"MultiSelectSubmitMsg",
|
|
183
|
+
"ConfirmPrompt",
|
|
184
|
+
"ConfirmSubmitMsg",
|
|
185
|
+
# Toast
|
|
186
|
+
"ToastManager",
|
|
187
|
+
"ToastItem",
|
|
188
|
+
"ToastLevel",
|
|
189
|
+
"ToastDismissMsg",
|
|
190
|
+
# Tabs
|
|
191
|
+
"Tabs",
|
|
192
|
+
"TabStyle",
|
|
193
|
+
"TabChangeMsg",
|
|
194
|
+
# Tree
|
|
195
|
+
"Tree",
|
|
196
|
+
"TreeNode",
|
|
197
|
+
"TreeNodeSelectMsg",
|
|
198
|
+
# StatusBar
|
|
199
|
+
"StatusBar",
|
|
200
|
+
"StatusSection",
|
|
201
|
+
# Metric
|
|
202
|
+
"Metric",
|
|
203
|
+
"MetricGroup",
|
|
204
|
+
"MetricLayout",
|
|
205
|
+
"MetricTrend",
|
|
206
|
+
"LayoutDirection",
|
|
207
|
+
# NavStack
|
|
208
|
+
"NavStack",
|
|
209
|
+
"NavEntry",
|
|
210
|
+
"NavPushMsg",
|
|
211
|
+
"NavPopMsg",
|
|
212
|
+
# DatePicker
|
|
213
|
+
"DatePicker",
|
|
214
|
+
"DatePickerFocus",
|
|
215
|
+
"DateSelectMsg",
|
|
216
|
+
"DateChangeMsg",
|
|
217
|
+
# CodeViewer
|
|
218
|
+
"CodeViewer",
|
|
219
|
+
"SyntaxTheme",
|
|
220
|
+
"THEME_ESPRESSO",
|
|
221
|
+
"THEME_DRACULA",
|
|
222
|
+
"THEME_MONOKAI",
|
|
223
|
+
"highlight_code",
|
|
224
|
+
# MarkdownViewer
|
|
225
|
+
"MarkdownViewer",
|
|
226
|
+
"render_markdown",
|
|
227
|
+
# PipelineProgress
|
|
228
|
+
"PipelineProgress",
|
|
229
|
+
"PipelineStage",
|
|
230
|
+
"StageStatus",
|
|
231
|
+
"StageStartMsg",
|
|
232
|
+
"StageCompleteMsg",
|
|
233
|
+
"StageFailedMsg",
|
|
234
|
+
"PipelineCompleteMsg",
|
|
235
|
+
# QuickFix
|
|
236
|
+
"QuickFix",
|
|
237
|
+
"QuickFixItem",
|
|
238
|
+
"QuickFixSelectMsg",
|
|
239
|
+
# DetailSelector
|
|
240
|
+
"DetailSelector",
|
|
241
|
+
"DetailItem",
|
|
242
|
+
"DetailSelectMsg",
|
|
243
|
+
# ImageViewer
|
|
244
|
+
"ImageViewer",
|
|
245
|
+
"RenderMode",
|
|
246
|
+
"parse_ppm",
|
|
247
|
+
"parse_bmp",
|
|
248
|
+
# Splitter
|
|
249
|
+
"Splitter",
|
|
250
|
+
"SplitterOrientation",
|
|
251
|
+
"SplitterResizeMsg",
|
|
252
|
+
# Slider
|
|
253
|
+
"Slider",
|
|
254
|
+
"RangeSlider",
|
|
255
|
+
"SliderChangeMsg",
|
|
256
|
+
"RangeSliderChangeMsg",
|
|
257
|
+
# Sparkline
|
|
258
|
+
"Sparkline",
|
|
259
|
+
"SparklineMode",
|
|
260
|
+
"SparklineTickMsg",
|
|
261
|
+
# Marquee
|
|
262
|
+
"Marquee",
|
|
263
|
+
"MarqueeMode",
|
|
264
|
+
"MarqueeTickMsg",
|
|
265
|
+
# SortableList
|
|
266
|
+
"SortableList",
|
|
267
|
+
"SortableItem",
|
|
268
|
+
"ItemReorderedMsg",
|
|
269
|
+
# Spring
|
|
270
|
+
"Spring",
|
|
271
|
+
"SpringValue",
|
|
272
|
+
"SpringTickMsg",
|
|
273
|
+
# Confetti
|
|
274
|
+
"Confetti",
|
|
275
|
+
"ConfettiMode",
|
|
276
|
+
"ConfettiTickMsg",
|
|
277
|
+
"Particle",
|
|
278
|
+
# DiffViewer
|
|
279
|
+
"DiffViewer",
|
|
280
|
+
"DiffMode",
|
|
281
|
+
"DiffHunk",
|
|
282
|
+
"DiffLine",
|
|
283
|
+
# Form
|
|
284
|
+
"Form",
|
|
285
|
+
"FormField",
|
|
286
|
+
"FormSubmitMsg",
|
|
287
|
+
# BarChart
|
|
288
|
+
"BarChart",
|
|
289
|
+
"BarItem",
|
|
290
|
+
"BarOrientation",
|
|
291
|
+
"BarChartSelectMsg",
|
|
292
|
+
# CommandPalette
|
|
293
|
+
"CommandPalette",
|
|
294
|
+
"PaletteItem",
|
|
295
|
+
"CommandPaletteSelectMsg",
|
|
296
|
+
"CommandPaletteCloseMsg",
|
|
297
|
+
# GitTree
|
|
298
|
+
"GitTree",
|
|
299
|
+
"GitFileNode",
|
|
300
|
+
"GitFileStatus",
|
|
301
|
+
"GitTreeSelectMsg",
|
|
302
|
+
"GitTreeToggleMsg",
|
|
303
|
+
]
|
|
304
|
+
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Bar chart bean with horizontal & vertical modes, auto-scaling, and TrueColor gradients.
|
|
2
|
+
|
|
3
|
+
Inspired by rich, asciigraph, and modern terminal analytics dashboards.
|
|
4
|
+
Supports sub-character precision bar rendering, TrueColor linear/multi-gradients,
|
|
5
|
+
interactive cursor navigation, value formatters, and mouse selection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Callable, Sequence
|
|
13
|
+
|
|
14
|
+
from espresso.core.keys import KeyMsg
|
|
15
|
+
from espresso.core.mouse import MouseAction, MouseButton, MouseMsg
|
|
16
|
+
from espresso.core.tea import Cmd, Model, Msg
|
|
17
|
+
from espresso.crema.border import ROUNDED_BORDER
|
|
18
|
+
from espresso.crema.gradient import multi_gradient_colors
|
|
19
|
+
from espresso.crema.style import Align, Style
|
|
20
|
+
from espresso.crema.width import string_width, strip_ansi, truncate_ansi
|
|
21
|
+
|
|
22
|
+
# Unicode fractional blocks for horizontal sub-character precision
|
|
23
|
+
HORIZ_BLOCKS = (" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█")
|
|
24
|
+
|
|
25
|
+
# Unicode vertical blocks for vertical columns
|
|
26
|
+
VERT_BLOCKS = (" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BarOrientation(str, Enum):
|
|
30
|
+
"""Orientation of the bar chart."""
|
|
31
|
+
|
|
32
|
+
HORIZONTAL = "horizontal"
|
|
33
|
+
VERTICAL = "vertical"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BarItem:
|
|
38
|
+
"""An individual data point/bar in the chart."""
|
|
39
|
+
|
|
40
|
+
label: str
|
|
41
|
+
value: float
|
|
42
|
+
color: str | None = None
|
|
43
|
+
formatter: Callable[[float], str] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BarChartSelectMsg(Msg):
|
|
48
|
+
"""Emitted when a bar is selected in the BarChart."""
|
|
49
|
+
|
|
50
|
+
item: BarItem
|
|
51
|
+
index: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BarChart(Model):
|
|
55
|
+
"""Interactive bar chart component with auto-scaling and TrueColor styling."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
items: Sequence[BarItem] = (),
|
|
60
|
+
title: str = "Bar Chart",
|
|
61
|
+
orientation: BarOrientation = BarOrientation.HORIZONTAL,
|
|
62
|
+
width: int = 44,
|
|
63
|
+
height: int = 12,
|
|
64
|
+
min_value: float = 0.0,
|
|
65
|
+
max_value: float | None = None,
|
|
66
|
+
gradient_colors: tuple[str, ...] = ("#00E5FF", "#7D56F4", "#F7768E"),
|
|
67
|
+
show_values: bool = True,
|
|
68
|
+
show_labels: bool = True,
|
|
69
|
+
cursor_enabled: bool = True,
|
|
70
|
+
border_color: str = "#7D56F4",
|
|
71
|
+
) -> None:
|
|
72
|
+
self.items: list[BarItem] = list(items)
|
|
73
|
+
self.title = title
|
|
74
|
+
self.orientation = orientation
|
|
75
|
+
self.width = max(20, width)
|
|
76
|
+
self.height = max(5, height)
|
|
77
|
+
self.min_value = min_value
|
|
78
|
+
self.max_value = max_value
|
|
79
|
+
self.gradient_colors = gradient_colors
|
|
80
|
+
self.show_values = show_values
|
|
81
|
+
self.show_labels = show_labels
|
|
82
|
+
self.cursor_enabled = cursor_enabled
|
|
83
|
+
self.border_color = border_color
|
|
84
|
+
|
|
85
|
+
self.cursor: int = 0
|
|
86
|
+
self.offset_x: int = 0
|
|
87
|
+
self.offset_y: int = 0
|
|
88
|
+
|
|
89
|
+
def set_items(self, items: Sequence[BarItem]) -> None:
|
|
90
|
+
"""Update chart data."""
|
|
91
|
+
self.items = list(items)
|
|
92
|
+
if self.cursor >= len(self.items):
|
|
93
|
+
self.cursor = max(0, len(self.items) - 1)
|
|
94
|
+
|
|
95
|
+
def set_offset(self, x: int, y: int) -> None:
|
|
96
|
+
"""Set screen offset for mouse hit testing."""
|
|
97
|
+
self.offset_x = x
|
|
98
|
+
self.offset_y = y
|
|
99
|
+
|
|
100
|
+
def _calc_max(self) -> float:
|
|
101
|
+
if self.max_value is not None:
|
|
102
|
+
return self.max_value
|
|
103
|
+
if not self.items:
|
|
104
|
+
return 1.0
|
|
105
|
+
return max((it.value for it in self.items), default=1.0) or 1.0
|
|
106
|
+
|
|
107
|
+
def update(self, msg: Msg) -> tuple[Model, Cmd]:
|
|
108
|
+
"""Handle keyboard navigation and mouse interactions."""
|
|
109
|
+
if not self.items:
|
|
110
|
+
return self, None
|
|
111
|
+
|
|
112
|
+
if isinstance(msg, KeyMsg):
|
|
113
|
+
key = str(msg.key).lower()
|
|
114
|
+
|
|
115
|
+
if self.orientation == BarOrientation.HORIZONTAL:
|
|
116
|
+
if key in ("up", "k"):
|
|
117
|
+
self.cursor = max(0, self.cursor - 1)
|
|
118
|
+
return self, None
|
|
119
|
+
elif key in ("down", "j"):
|
|
120
|
+
self.cursor = min(len(self.items) - 1, self.cursor + 1)
|
|
121
|
+
return self, None
|
|
122
|
+
else:
|
|
123
|
+
if key in ("left", "h"):
|
|
124
|
+
self.cursor = max(0, self.cursor - 1)
|
|
125
|
+
return self, None
|
|
126
|
+
elif key in ("right", "l"):
|
|
127
|
+
self.cursor = min(len(self.items) - 1, self.cursor + 1)
|
|
128
|
+
return self, None
|
|
129
|
+
|
|
130
|
+
if key == "enter":
|
|
131
|
+
if 0 <= self.cursor < len(self.items):
|
|
132
|
+
item = self.items[self.cursor]
|
|
133
|
+
idx = self.cursor
|
|
134
|
+
return self, lambda: BarChartSelectMsg(item=item, index=idx)
|
|
135
|
+
|
|
136
|
+
elif isinstance(msg, MouseMsg):
|
|
137
|
+
rel_x = msg.x - self.offset_x
|
|
138
|
+
rel_y = msg.y - self.offset_y
|
|
139
|
+
|
|
140
|
+
if self.orientation == BarOrientation.HORIZONTAL:
|
|
141
|
+
# Rows start at y=1 (inside border)
|
|
142
|
+
idx = rel_y - 1
|
|
143
|
+
if 0 <= idx < len(self.items):
|
|
144
|
+
if msg.action in (MouseAction.PRESS, MouseAction.DOUBLE_CLICK):
|
|
145
|
+
self.cursor = idx
|
|
146
|
+
item = self.items[idx]
|
|
147
|
+
return self, lambda: BarChartSelectMsg(item=item, index=idx)
|
|
148
|
+
else:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
return self, None
|
|
152
|
+
|
|
153
|
+
def _render_horizontal(self, inner_w: int) -> list[str]:
|
|
154
|
+
lines: list[str] = []
|
|
155
|
+
max_v = self._calc_max()
|
|
156
|
+
|
|
157
|
+
max_label_w = max((string_width(it.label) for it in self.items), default=0) if self.show_labels else 0
|
|
158
|
+
max_label_w = min(max_label_w, inner_w // 3)
|
|
159
|
+
|
|
160
|
+
# Estimate value text width
|
|
161
|
+
sample_vals = [it.formatter(it.value) if it.formatter else f"{it.value:.1f}" for it in self.items]
|
|
162
|
+
max_val_w = max((string_width(v) for v in sample_vals), default=0) if self.show_values else 0
|
|
163
|
+
|
|
164
|
+
bar_space = max(5, inner_w - max_label_w - max_val_w - (3 if max_label_w else 0) - (2 if max_val_w else 0) - 2)
|
|
165
|
+
|
|
166
|
+
grad_stops = multi_gradient_colors(self.gradient_colors, max(2, bar_space))
|
|
167
|
+
|
|
168
|
+
for idx, it in enumerate(self.items):
|
|
169
|
+
is_selected = self.cursor_enabled and idx == self.cursor
|
|
170
|
+
|
|
171
|
+
# Label part
|
|
172
|
+
if self.show_labels:
|
|
173
|
+
lbl = truncate_ansi(it.label, max_label_w)
|
|
174
|
+
lw = string_width(lbl)
|
|
175
|
+
label_part = f"{lbl}{' ' * (max_label_w - lw)} │ "
|
|
176
|
+
else:
|
|
177
|
+
label_part = ""
|
|
178
|
+
|
|
179
|
+
# Value fraction
|
|
180
|
+
norm = max(0.0, min(1.0, (it.value - self.min_value) / max(0.001, (max_v - self.min_value))))
|
|
181
|
+
raw_len = norm * bar_space
|
|
182
|
+
full_blocks = int(raw_len)
|
|
183
|
+
frac_idx = int((raw_len - full_blocks) * 8)
|
|
184
|
+
|
|
185
|
+
bar_chars: list[str] = []
|
|
186
|
+
for b_i in range(full_blocks):
|
|
187
|
+
color = it.color or grad_stops[min(b_i, len(grad_stops) - 1)]
|
|
188
|
+
bar_chars.append(Style().foreground(color).render("█"))
|
|
189
|
+
|
|
190
|
+
if frac_idx > 0 and full_blocks < bar_space:
|
|
191
|
+
color = it.color or grad_stops[min(full_blocks, len(grad_stops) - 1)]
|
|
192
|
+
bar_chars.append(Style().foreground(color).render(HORIZ_BLOCKS[frac_idx]))
|
|
193
|
+
full_blocks += 1
|
|
194
|
+
|
|
195
|
+
# Pad empty space
|
|
196
|
+
empty_space = " " * max(0, bar_space - full_blocks)
|
|
197
|
+
bar_part = "".join(bar_chars) + empty_space
|
|
198
|
+
|
|
199
|
+
# Value part
|
|
200
|
+
if self.show_values:
|
|
201
|
+
v_str = it.formatter(it.value) if it.formatter else f"{it.value:.1f}"
|
|
202
|
+
val_part = f" {Style().bold(True).render(v_str)}"
|
|
203
|
+
else:
|
|
204
|
+
val_part = ""
|
|
205
|
+
|
|
206
|
+
prefix = "› " if is_selected else " "
|
|
207
|
+
pref_st = Style().foreground("#00E5FF").bold(True).render(prefix) if is_selected else prefix
|
|
208
|
+
|
|
209
|
+
row = f"{pref_st}{label_part}{bar_part}{val_part}"
|
|
210
|
+
row = truncate_ansi(row, inner_w)
|
|
211
|
+
rw = string_width(row)
|
|
212
|
+
if rw < inner_w:
|
|
213
|
+
row += " " * (inner_w - rw)
|
|
214
|
+
|
|
215
|
+
if is_selected:
|
|
216
|
+
row = Style().background("#2A2A3D").render(row)
|
|
217
|
+
|
|
218
|
+
lines.append(row)
|
|
219
|
+
|
|
220
|
+
return lines
|
|
221
|
+
|
|
222
|
+
def _render_vertical(self, inner_w: int) -> list[str]:
|
|
223
|
+
lines: list[str] = []
|
|
224
|
+
max_v = self._calc_max()
|
|
225
|
+
# Reserved height: 1 for x-axis line, 1 for labels
|
|
226
|
+
chart_h = max(2, self.height - 4)
|
|
227
|
+
|
|
228
|
+
if not self.items:
|
|
229
|
+
return [" " * inner_w] * chart_h
|
|
230
|
+
|
|
231
|
+
col_w = max(2, inner_w // len(self.items))
|
|
232
|
+
grad_stops = multi_gradient_colors(self.gradient_colors, max(2, chart_h))
|
|
233
|
+
|
|
234
|
+
grid: list[list[str]] = [[" " * col_w for _ in self.items] for _ in range(chart_h)]
|
|
235
|
+
|
|
236
|
+
for col_idx, it in enumerate(self.items):
|
|
237
|
+
norm = max(0.0, min(1.0, (it.value - self.min_value) / max(0.001, (max_v - self.min_value))))
|
|
238
|
+
raw_h = norm * chart_h
|
|
239
|
+
full_blocks = int(raw_h)
|
|
240
|
+
frac_idx = int((raw_h - full_blocks) * 8)
|
|
241
|
+
|
|
242
|
+
for h in range(full_blocks):
|
|
243
|
+
row_idx = chart_h - 1 - h
|
|
244
|
+
color = it.color or grad_stops[min(h, len(grad_stops) - 1)]
|
|
245
|
+
grid[row_idx][col_idx] = Style().foreground(color).render("█" * (col_w - 1) + " ")
|
|
246
|
+
|
|
247
|
+
if frac_idx > 0 and full_blocks < chart_h:
|
|
248
|
+
row_idx = chart_h - 1 - full_blocks
|
|
249
|
+
color = it.color or grad_stops[min(full_blocks, len(grad_stops) - 1)]
|
|
250
|
+
block_ch = VERT_BLOCKS[frac_idx]
|
|
251
|
+
grid[row_idx][col_idx] = Style().foreground(color).render((block_ch * (col_w - 1)) + " ")
|
|
252
|
+
|
|
253
|
+
for row in grid:
|
|
254
|
+
r_str = "".join(row)
|
|
255
|
+
r_w = string_width(r_str)
|
|
256
|
+
if r_w < inner_w:
|
|
257
|
+
r_str += " " * (inner_w - r_w)
|
|
258
|
+
lines.append(r_str)
|
|
259
|
+
|
|
260
|
+
# Axis line
|
|
261
|
+
lines.append(Style().foreground("#555555").render("─" * inner_w))
|
|
262
|
+
|
|
263
|
+
# Labels line
|
|
264
|
+
lbl_parts: list[str] = []
|
|
265
|
+
for i, it in enumerate(self.items):
|
|
266
|
+
is_sel = self.cursor_enabled and i == self.cursor
|
|
267
|
+
lbl = truncate_ansi(it.label, col_w - 1)
|
|
268
|
+
lw = string_width(lbl)
|
|
269
|
+
padded = lbl + (" " * (col_w - lw))
|
|
270
|
+
if is_sel:
|
|
271
|
+
padded = Style().foreground("#00E5FF").bold(True).render(padded)
|
|
272
|
+
lbl_parts.append(padded)
|
|
273
|
+
l_row = "".join(lbl_parts)
|
|
274
|
+
l_w = string_width(l_row)
|
|
275
|
+
if l_w < inner_w:
|
|
276
|
+
l_row += " " * (inner_w - l_w)
|
|
277
|
+
lines.append(l_row)
|
|
278
|
+
|
|
279
|
+
return lines
|
|
280
|
+
|
|
281
|
+
def view(self) -> str:
|
|
282
|
+
"""Render the bar chart."""
|
|
283
|
+
inner_w = self.width - 2
|
|
284
|
+
|
|
285
|
+
if self.orientation == BarOrientation.HORIZONTAL:
|
|
286
|
+
lines = self._render_horizontal(inner_w)
|
|
287
|
+
else:
|
|
288
|
+
lines = self._render_vertical(inner_w)
|
|
289
|
+
|
|
290
|
+
# Pad height
|
|
291
|
+
while len(lines) < self.height - 2:
|
|
292
|
+
lines.append(" " * inner_w)
|
|
293
|
+
|
|
294
|
+
border_st = (
|
|
295
|
+
Style()
|
|
296
|
+
.border(ROUNDED_BORDER)
|
|
297
|
+
.border_foreground(self.border_color)
|
|
298
|
+
.border_title(f" {self.title} " if self.title else None)
|
|
299
|
+
.width(inner_w)
|
|
300
|
+
)
|
|
301
|
+
return border_st.render("\n".join(lines))
|