violit 0.0.4.post1__py3-none-any.whl → 0.0.5__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.
- violit/app.py +2229 -1988
- violit/context.py +1 -0
- violit/state.py +33 -0
- violit/widgets/__init__.py +30 -30
- violit/widgets/card_widgets.py +595 -595
- violit/widgets/data_widgets.py +529 -529
- violit/widgets/layout_widgets.py +419 -419
- violit/widgets/text_widgets.py +413 -413
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/METADATA +1 -1
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/RECORD +13 -13
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/WHEEL +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/licenses/LICENSE +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/top_level.txt +0 -0
violit/widgets/text_widgets.py
CHANGED
|
@@ -1,413 +1,413 @@
|
|
|
1
|
-
"""Text widgets"""
|
|
2
|
-
|
|
3
|
-
from typing import Union, Callable, Optional
|
|
4
|
-
from ..component import Component
|
|
5
|
-
from ..context import rendering_ctx
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TextWidgetsMixin:
|
|
9
|
-
def write(self, *args, tag: Optional[str] = "div", unsafe_allow_html: bool = False, **props):
|
|
10
|
-
"""Display content with automatic type detection"""
|
|
11
|
-
from ..state import State
|
|
12
|
-
import re
|
|
13
|
-
import json
|
|
14
|
-
import html as html_lib
|
|
15
|
-
|
|
16
|
-
cid = self._get_next_cid("comp")
|
|
17
|
-
|
|
18
|
-
def builder():
|
|
19
|
-
def _has_markdown(text: str) -> bool:
|
|
20
|
-
"""Check if text contains markdown syntax"""
|
|
21
|
-
markdown_patterns = [
|
|
22
|
-
r'^#{1,6}\s', # Headers: # ## ###
|
|
23
|
-
r'\*\*[^*]+\*\*', # Bold: **text**
|
|
24
|
-
r'(?<!\*)\*[^*\n]+\*', # Italic: *text*
|
|
25
|
-
r'`[^`]+`', # Code: `text`
|
|
26
|
-
r'\[.+?\]\(.+?\)', # Links: [text](url)
|
|
27
|
-
r'^[-*]\s', # Lists: - or *
|
|
28
|
-
r'^\d+\.\s', # Numbered lists: 1. 2.
|
|
29
|
-
]
|
|
30
|
-
for pattern in markdown_patterns:
|
|
31
|
-
if re.search(pattern, text, re.MULTILINE):
|
|
32
|
-
return True
|
|
33
|
-
return False
|
|
34
|
-
|
|
35
|
-
# Set rendering context once for entire builder
|
|
36
|
-
token = rendering_ctx.set(cid)
|
|
37
|
-
parts = []
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
for arg in args:
|
|
41
|
-
current_value = arg
|
|
42
|
-
|
|
43
|
-
# State object: read value (registers dependency)
|
|
44
|
-
if isinstance(arg, State):
|
|
45
|
-
current_value = arg.value
|
|
46
|
-
|
|
47
|
-
# Callable/Lambda: execute (registers dependency)
|
|
48
|
-
elif callable(arg):
|
|
49
|
-
current_value = arg()
|
|
50
|
-
|
|
51
|
-
# DataFrame (pandas)
|
|
52
|
-
try:
|
|
53
|
-
import pandas as pd
|
|
54
|
-
if isinstance(current_value, pd.DataFrame):
|
|
55
|
-
parts.append(self._render_dataframe_html(current_value))
|
|
56
|
-
continue
|
|
57
|
-
except (ImportError, AttributeError):
|
|
58
|
-
pass
|
|
59
|
-
|
|
60
|
-
# Dict or List → JSON
|
|
61
|
-
if isinstance(current_value, (dict, list, tuple)):
|
|
62
|
-
json_str = json.dumps(current_value, indent=2, ensure_ascii=False)
|
|
63
|
-
parts.append(f'<pre style="background:var(--sl-bg-card);padding:1rem;border-radius:0.5rem;border:1px solid var(--sl-border);overflow-x:auto;"><code style="color:var(--sl-text);font-family:monospace;">{html_lib.escape(json_str)}</code></pre>')
|
|
64
|
-
continue
|
|
65
|
-
|
|
66
|
-
# String with markdown → render as markdown
|
|
67
|
-
text = str(current_value)
|
|
68
|
-
if _has_markdown(text):
|
|
69
|
-
parts.append(self._render_markdown(text))
|
|
70
|
-
else:
|
|
71
|
-
# Plain text
|
|
72
|
-
parts.append(text)
|
|
73
|
-
|
|
74
|
-
# Join all parts
|
|
75
|
-
content = " ".join(parts)
|
|
76
|
-
|
|
77
|
-
# Check if any HTML in content
|
|
78
|
-
has_html = '<' in content and '>' in content
|
|
79
|
-
return Component(tag, id=cid, content=content, escape_content=not (has_html or unsafe_allow_html), **props)
|
|
80
|
-
|
|
81
|
-
finally:
|
|
82
|
-
rendering_ctx.reset(token)
|
|
83
|
-
|
|
84
|
-
self._register_component(cid, builder)
|
|
85
|
-
|
|
86
|
-
def _render_markdown(self, text: str) -> str:
|
|
87
|
-
"""Render markdown to HTML (internal helper)"""
|
|
88
|
-
import re
|
|
89
|
-
lines = text.split('\n')
|
|
90
|
-
result = []
|
|
91
|
-
i = 0
|
|
92
|
-
|
|
93
|
-
while i < len(lines):
|
|
94
|
-
line = lines[i]
|
|
95
|
-
stripped = line.strip()
|
|
96
|
-
|
|
97
|
-
# Headers
|
|
98
|
-
if stripped.startswith('### '):
|
|
99
|
-
result.append(f'<h3>{stripped[4:]}</h3>')
|
|
100
|
-
i += 1
|
|
101
|
-
elif stripped.startswith('## '):
|
|
102
|
-
result.append(f'<h2>{stripped[3:]}</h2>')
|
|
103
|
-
i += 1
|
|
104
|
-
elif stripped.startswith('# '):
|
|
105
|
-
result.append(f'<h1>{stripped[2:]}</h1>')
|
|
106
|
-
i += 1
|
|
107
|
-
# Unordered lists
|
|
108
|
-
elif stripped.startswith(('- ', '* ')):
|
|
109
|
-
list_items = []
|
|
110
|
-
while i < len(lines):
|
|
111
|
-
curr = lines[i].strip()
|
|
112
|
-
if curr.startswith(('- ', '* ')):
|
|
113
|
-
list_items.append(f'<li>{curr[2:]}</li>')
|
|
114
|
-
i += 1
|
|
115
|
-
elif not curr:
|
|
116
|
-
i += 1
|
|
117
|
-
break
|
|
118
|
-
else:
|
|
119
|
-
break
|
|
120
|
-
result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
|
|
121
|
-
# Ordered lists
|
|
122
|
-
elif re.match(r'^\d+\.\s', stripped):
|
|
123
|
-
list_items = []
|
|
124
|
-
while i < len(lines):
|
|
125
|
-
curr = lines[i].strip()
|
|
126
|
-
if re.match(r'^\d+\.\s', curr):
|
|
127
|
-
clean_item = re.sub(r'^\d+\.\s', '', curr)
|
|
128
|
-
list_items.append(f'<li>{clean_item}</li>')
|
|
129
|
-
i += 1
|
|
130
|
-
elif not curr:
|
|
131
|
-
i += 1
|
|
132
|
-
break
|
|
133
|
-
else:
|
|
134
|
-
break
|
|
135
|
-
result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
|
|
136
|
-
# Empty line
|
|
137
|
-
elif not stripped:
|
|
138
|
-
result.append('<br>')
|
|
139
|
-
i += 1
|
|
140
|
-
# Regular text
|
|
141
|
-
else:
|
|
142
|
-
result.append(line)
|
|
143
|
-
i += 1
|
|
144
|
-
|
|
145
|
-
html = '\n'.join(result)
|
|
146
|
-
|
|
147
|
-
# Inline elements
|
|
148
|
-
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
|
|
149
|
-
html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
|
|
150
|
-
html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
|
|
151
|
-
html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
|
|
152
|
-
|
|
153
|
-
return html
|
|
154
|
-
|
|
155
|
-
def _render_dataframe_html(self, df) -> str:
|
|
156
|
-
"""Render pandas DataFrame as HTML table (internal helper)"""
|
|
157
|
-
# Use pandas to_html with custom styling
|
|
158
|
-
html = df.to_html(
|
|
159
|
-
index=True,
|
|
160
|
-
escape=True,
|
|
161
|
-
classes='dataframe',
|
|
162
|
-
border=0
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
# Add custom styling
|
|
166
|
-
styled_html = f'''
|
|
167
|
-
<div style="overflow-x: auto; margin: 1rem 0;">
|
|
168
|
-
<style>
|
|
169
|
-
.dataframe {{
|
|
170
|
-
border-collapse: collapse;
|
|
171
|
-
width: 100%;
|
|
172
|
-
font-size: 0.9rem;
|
|
173
|
-
}}
|
|
174
|
-
.dataframe th {{
|
|
175
|
-
background: var(--sl-color-primary-600);
|
|
176
|
-
color: white;
|
|
177
|
-
padding: 0.75rem;
|
|
178
|
-
text-align: left;
|
|
179
|
-
font-weight: 600;
|
|
180
|
-
}}
|
|
181
|
-
.dataframe td {{
|
|
182
|
-
padding: 0.5rem 0.75rem;
|
|
183
|
-
border-bottom: 1px solid var(--sl-color-neutral-200);
|
|
184
|
-
}}
|
|
185
|
-
.dataframe tr:hover {{
|
|
186
|
-
background: var(--sl-color-neutral-50);
|
|
187
|
-
}}
|
|
188
|
-
</style>
|
|
189
|
-
{html}
|
|
190
|
-
</div>
|
|
191
|
-
'''
|
|
192
|
-
return styled_html
|
|
193
|
-
|
|
194
|
-
def heading(self, text, level: int = 1, divider: bool = False):
|
|
195
|
-
"""Display heading (h1-h6)"""
|
|
196
|
-
from ..state import State
|
|
197
|
-
import html as html_lib
|
|
198
|
-
|
|
199
|
-
cid = self._get_next_cid("heading")
|
|
200
|
-
def builder():
|
|
201
|
-
token = rendering_ctx.set(cid)
|
|
202
|
-
if isinstance(text, State):
|
|
203
|
-
content = text.value
|
|
204
|
-
elif callable(text):
|
|
205
|
-
content = text()
|
|
206
|
-
else:
|
|
207
|
-
content = text
|
|
208
|
-
rendering_ctx.reset(token)
|
|
209
|
-
|
|
210
|
-
# XSS protection: escape content
|
|
211
|
-
escaped_content = html_lib.escape(str(content))
|
|
212
|
-
|
|
213
|
-
grad = "gradient-text" if level == 1 else ""
|
|
214
|
-
html_output = f'<h{level} class="{grad}">{escaped_content}</h{level}>'
|
|
215
|
-
if divider: html_output += '<sl-divider class="divider"></sl-divider>'
|
|
216
|
-
return Component("div", id=cid, content=html_output)
|
|
217
|
-
self._register_component(cid, builder)
|
|
218
|
-
|
|
219
|
-
def title(self, text: Union[str, Callable]):
|
|
220
|
-
"""Display title (h1 with gradient)"""
|
|
221
|
-
self.heading(text, level=1, divider=False)
|
|
222
|
-
|
|
223
|
-
def header(self, text: Union[str, Callable], divider: bool = True):
|
|
224
|
-
"""Display header (h2)"""
|
|
225
|
-
self.heading(text, level=2, divider=divider)
|
|
226
|
-
|
|
227
|
-
def subheader(self, text: Union[str, Callable], divider: bool = False):
|
|
228
|
-
"""Display subheader (h3)"""
|
|
229
|
-
self.heading(text, level=3, divider=divider)
|
|
230
|
-
|
|
231
|
-
def text(self, content, size: str = "medium", muted: bool = False):
|
|
232
|
-
"""Display text paragraph"""
|
|
233
|
-
from ..state import State
|
|
234
|
-
|
|
235
|
-
cid = self._get_next_cid("text")
|
|
236
|
-
def builder():
|
|
237
|
-
token = rendering_ctx.set(cid)
|
|
238
|
-
if isinstance(content, State):
|
|
239
|
-
val = content.value
|
|
240
|
-
elif callable(content):
|
|
241
|
-
val = content()
|
|
242
|
-
else:
|
|
243
|
-
val = content
|
|
244
|
-
rendering_ctx.reset(token)
|
|
245
|
-
cls = f"text-{size} {'text-muted' if muted else ''}"
|
|
246
|
-
# XSS protection: enable content escaping
|
|
247
|
-
return Component("p", id=cid, content=val, escape_content=True, class_=cls)
|
|
248
|
-
self._register_component(cid, builder)
|
|
249
|
-
|
|
250
|
-
def caption(self, text: Union[str, Callable]):
|
|
251
|
-
"""Display caption text (small, muted)"""
|
|
252
|
-
self.text(text, size="small", muted=True)
|
|
253
|
-
|
|
254
|
-
def markdown(self, text: Union[str, Callable], **props):
|
|
255
|
-
"""Display markdown-formatted text"""
|
|
256
|
-
cid = self._get_next_cid("markdown")
|
|
257
|
-
def builder():
|
|
258
|
-
token = rendering_ctx.set(cid)
|
|
259
|
-
content = text() if callable(text) else text
|
|
260
|
-
|
|
261
|
-
# Enhanced markdown conversion - line-by-line processing
|
|
262
|
-
import re
|
|
263
|
-
lines = content.split('\n')
|
|
264
|
-
result = []
|
|
265
|
-
i = 0
|
|
266
|
-
|
|
267
|
-
while i < len(lines):
|
|
268
|
-
line = lines[i]
|
|
269
|
-
stripped = line.strip()
|
|
270
|
-
|
|
271
|
-
# Headers
|
|
272
|
-
if stripped.startswith('### '):
|
|
273
|
-
result.append(f'<h3>{stripped[4:]}</h3>')
|
|
274
|
-
i += 1
|
|
275
|
-
elif stripped.startswith('## '):
|
|
276
|
-
result.append(f'<h2>{stripped[3:]}</h2>')
|
|
277
|
-
i += 1
|
|
278
|
-
elif stripped.startswith('# '):
|
|
279
|
-
result.append(f'<h1>{stripped[2:]}</h1>')
|
|
280
|
-
i += 1
|
|
281
|
-
# Unordered lists
|
|
282
|
-
elif stripped.startswith(('- ', '* ')):
|
|
283
|
-
list_items = []
|
|
284
|
-
while i < len(lines):
|
|
285
|
-
curr = lines[i].strip()
|
|
286
|
-
if curr.startswith(('- ', '* ')):
|
|
287
|
-
list_items.append(f'<li>{curr[2:]}</li>')
|
|
288
|
-
i += 1
|
|
289
|
-
elif not curr: # Empty line
|
|
290
|
-
i += 1
|
|
291
|
-
break
|
|
292
|
-
else:
|
|
293
|
-
break
|
|
294
|
-
result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
|
|
295
|
-
# Ordered lists
|
|
296
|
-
elif re.match(r'^\d+\.\s', stripped):
|
|
297
|
-
list_items = []
|
|
298
|
-
while i < len(lines):
|
|
299
|
-
curr = lines[i].strip()
|
|
300
|
-
if re.match(r'^\d+\.\s', curr):
|
|
301
|
-
clean_item = re.sub(r'^\d+\.\s', '', curr)
|
|
302
|
-
list_items.append(f'<li>{clean_item}</li>')
|
|
303
|
-
i += 1
|
|
304
|
-
elif not curr: # Empty line
|
|
305
|
-
i += 1
|
|
306
|
-
break
|
|
307
|
-
else:
|
|
308
|
-
break
|
|
309
|
-
result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
|
|
310
|
-
# Empty line
|
|
311
|
-
elif not stripped:
|
|
312
|
-
result.append('<br>')
|
|
313
|
-
i += 1
|
|
314
|
-
# Regular text
|
|
315
|
-
else:
|
|
316
|
-
result.append(line)
|
|
317
|
-
i += 1
|
|
318
|
-
|
|
319
|
-
html = '\n'.join(result)
|
|
320
|
-
|
|
321
|
-
# Inline elements (bold, italic, code, links)
|
|
322
|
-
# Bold **text** (before italic to avoid conflicts)
|
|
323
|
-
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
|
|
324
|
-
# Italic *text* (avoid matching list markers)
|
|
325
|
-
html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
|
|
326
|
-
# Code `text`
|
|
327
|
-
html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
|
|
328
|
-
# Links [text](url)
|
|
329
|
-
html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
|
|
330
|
-
|
|
331
|
-
rendering_ctx.reset(token)
|
|
332
|
-
return Component("div", id=cid, content=html, class_="markdown", **props)
|
|
333
|
-
self._register_component(cid, builder)
|
|
334
|
-
|
|
335
|
-
def html(self, html_content: Union[str, Callable], **props):
|
|
336
|
-
"""Display raw HTML content
|
|
337
|
-
|
|
338
|
-
Use this when you need to render HTML directly without markdown processing.
|
|
339
|
-
For markdown formatting, use app.markdown() instead.
|
|
340
|
-
|
|
341
|
-
Example:
|
|
342
|
-
app.html('<div class="custom">Hello</div>')
|
|
343
|
-
"""
|
|
344
|
-
cid = self._get_next_cid("html")
|
|
345
|
-
def builder():
|
|
346
|
-
token = rendering_ctx.set(cid)
|
|
347
|
-
content = html_content() if callable(html_content) else html_content
|
|
348
|
-
rendering_ctx.reset(token)
|
|
349
|
-
return Component("div", id=cid, content=content, **props)
|
|
350
|
-
self._register_component(cid, builder)
|
|
351
|
-
|
|
352
|
-
def code(self, code: Union[str, Callable], language: Optional[str] = None, **props):
|
|
353
|
-
"""Display code block with syntax highlighting"""
|
|
354
|
-
import html as html_lib
|
|
355
|
-
|
|
356
|
-
cid = self._get_next_cid("code")
|
|
357
|
-
def builder():
|
|
358
|
-
token = rendering_ctx.set(cid)
|
|
359
|
-
code_text = code() if callable(code) else code
|
|
360
|
-
rendering_ctx.reset(token)
|
|
361
|
-
|
|
362
|
-
# XSS protection: escape code content
|
|
363
|
-
escaped_code = html_lib.escape(str(code_text))
|
|
364
|
-
|
|
365
|
-
lang_class = f"language-{language}" if language else ""
|
|
366
|
-
html_output = f'''
|
|
367
|
-
<pre style="background:var(--sl-bg-card);padding:1rem;border-radius:0.5rem;border:1px solid var(--sl-border);overflow-x:auto;">
|
|
368
|
-
<code class="{lang_class}" style="color:var(--sl-text);font-family:monospace;">{escaped_code}</code>
|
|
369
|
-
</pre>
|
|
370
|
-
'''
|
|
371
|
-
return Component("div", id=cid, content=html_output, **props)
|
|
372
|
-
self._register_component(cid, builder)
|
|
373
|
-
|
|
374
|
-
def html(self, html_content: Union[str, Callable], **props):
|
|
375
|
-
"""Render raw HTML"""
|
|
376
|
-
cid = self._get_next_cid("html")
|
|
377
|
-
def builder():
|
|
378
|
-
token = rendering_ctx.set(cid)
|
|
379
|
-
content = html_content() if callable(html_content) else html_content
|
|
380
|
-
rendering_ctx.reset(token)
|
|
381
|
-
return Component("div", id=cid, content=content, **props)
|
|
382
|
-
self._register_component(cid, builder)
|
|
383
|
-
|
|
384
|
-
def divider(self):
|
|
385
|
-
"""Display horizontal divider"""
|
|
386
|
-
cid = self._get_next_cid("divider")
|
|
387
|
-
def builder():
|
|
388
|
-
return Component("sl-divider", id=cid, class_="divider")
|
|
389
|
-
self._register_component(cid, builder)
|
|
390
|
-
|
|
391
|
-
def success(self, body, icon="check-circle"):
|
|
392
|
-
"""Display success message"""
|
|
393
|
-
self._alert(body, "success", icon)
|
|
394
|
-
|
|
395
|
-
def info(self, body, icon="info-circle"):
|
|
396
|
-
"""Display info message"""
|
|
397
|
-
self._alert(body, "primary", icon)
|
|
398
|
-
|
|
399
|
-
def warning(self, body, icon="exclamation-triangle"):
|
|
400
|
-
"""Display warning message"""
|
|
401
|
-
self._alert(body, "warning", icon)
|
|
402
|
-
|
|
403
|
-
def error(self, body, icon="exclamation-octagon"):
|
|
404
|
-
"""Display error message"""
|
|
405
|
-
self._alert(body, "danger", icon)
|
|
406
|
-
|
|
407
|
-
def _alert(self, body, variant, icon_name):
|
|
408
|
-
cid = self._get_next_cid("alert")
|
|
409
|
-
def builder():
|
|
410
|
-
icon_html = f'<sl-icon slot="icon" name="{icon_name}"></sl-icon>'
|
|
411
|
-
html = f'<sl-alert variant="{variant}" open style="margin-bottom:1rem;">{icon_html}{body}</sl-alert>'
|
|
412
|
-
return Component("div", id=cid, content=html)
|
|
413
|
-
self._register_component(cid, builder)
|
|
1
|
+
"""Text widgets"""
|
|
2
|
+
|
|
3
|
+
from typing import Union, Callable, Optional
|
|
4
|
+
from ..component import Component
|
|
5
|
+
from ..context import rendering_ctx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TextWidgetsMixin:
|
|
9
|
+
def write(self, *args, tag: Optional[str] = "div", unsafe_allow_html: bool = False, **props):
|
|
10
|
+
"""Display content with automatic type detection"""
|
|
11
|
+
from ..state import State
|
|
12
|
+
import re
|
|
13
|
+
import json
|
|
14
|
+
import html as html_lib
|
|
15
|
+
|
|
16
|
+
cid = self._get_next_cid("comp")
|
|
17
|
+
|
|
18
|
+
def builder():
|
|
19
|
+
def _has_markdown(text: str) -> bool:
|
|
20
|
+
"""Check if text contains markdown syntax"""
|
|
21
|
+
markdown_patterns = [
|
|
22
|
+
r'^#{1,6}\s', # Headers: # ## ###
|
|
23
|
+
r'\*\*[^*]+\*\*', # Bold: **text**
|
|
24
|
+
r'(?<!\*)\*[^*\n]+\*', # Italic: *text*
|
|
25
|
+
r'`[^`]+`', # Code: `text`
|
|
26
|
+
r'\[.+?\]\(.+?\)', # Links: [text](url)
|
|
27
|
+
r'^[-*]\s', # Lists: - or *
|
|
28
|
+
r'^\d+\.\s', # Numbered lists: 1. 2.
|
|
29
|
+
]
|
|
30
|
+
for pattern in markdown_patterns:
|
|
31
|
+
if re.search(pattern, text, re.MULTILINE):
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
# Set rendering context once for entire builder
|
|
36
|
+
token = rendering_ctx.set(cid)
|
|
37
|
+
parts = []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
for arg in args:
|
|
41
|
+
current_value = arg
|
|
42
|
+
|
|
43
|
+
# State object: read value (registers dependency)
|
|
44
|
+
if isinstance(arg, State):
|
|
45
|
+
current_value = arg.value
|
|
46
|
+
|
|
47
|
+
# Callable/Lambda: execute (registers dependency)
|
|
48
|
+
elif callable(arg):
|
|
49
|
+
current_value = arg()
|
|
50
|
+
|
|
51
|
+
# DataFrame (pandas)
|
|
52
|
+
try:
|
|
53
|
+
import pandas as pd
|
|
54
|
+
if isinstance(current_value, pd.DataFrame):
|
|
55
|
+
parts.append(self._render_dataframe_html(current_value))
|
|
56
|
+
continue
|
|
57
|
+
except (ImportError, AttributeError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Dict or List → JSON
|
|
61
|
+
if isinstance(current_value, (dict, list, tuple)):
|
|
62
|
+
json_str = json.dumps(current_value, indent=2, ensure_ascii=False)
|
|
63
|
+
parts.append(f'<pre style="background:var(--sl-bg-card);padding:1rem;border-radius:0.5rem;border:1px solid var(--sl-border);overflow-x:auto;"><code style="color:var(--sl-text);font-family:monospace;">{html_lib.escape(json_str)}</code></pre>')
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# String with markdown → render as markdown
|
|
67
|
+
text = str(current_value)
|
|
68
|
+
if _has_markdown(text):
|
|
69
|
+
parts.append(self._render_markdown(text))
|
|
70
|
+
else:
|
|
71
|
+
# Plain text
|
|
72
|
+
parts.append(text)
|
|
73
|
+
|
|
74
|
+
# Join all parts
|
|
75
|
+
content = " ".join(parts)
|
|
76
|
+
|
|
77
|
+
# Check if any HTML in content
|
|
78
|
+
has_html = '<' in content and '>' in content
|
|
79
|
+
return Component(tag, id=cid, content=content, escape_content=not (has_html or unsafe_allow_html), **props)
|
|
80
|
+
|
|
81
|
+
finally:
|
|
82
|
+
rendering_ctx.reset(token)
|
|
83
|
+
|
|
84
|
+
self._register_component(cid, builder)
|
|
85
|
+
|
|
86
|
+
def _render_markdown(self, text: str) -> str:
|
|
87
|
+
"""Render markdown to HTML (internal helper)"""
|
|
88
|
+
import re
|
|
89
|
+
lines = text.split('\n')
|
|
90
|
+
result = []
|
|
91
|
+
i = 0
|
|
92
|
+
|
|
93
|
+
while i < len(lines):
|
|
94
|
+
line = lines[i]
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
|
|
97
|
+
# Headers
|
|
98
|
+
if stripped.startswith('### '):
|
|
99
|
+
result.append(f'<h3>{stripped[4:]}</h3>')
|
|
100
|
+
i += 1
|
|
101
|
+
elif stripped.startswith('## '):
|
|
102
|
+
result.append(f'<h2>{stripped[3:]}</h2>')
|
|
103
|
+
i += 1
|
|
104
|
+
elif stripped.startswith('# '):
|
|
105
|
+
result.append(f'<h1>{stripped[2:]}</h1>')
|
|
106
|
+
i += 1
|
|
107
|
+
# Unordered lists
|
|
108
|
+
elif stripped.startswith(('- ', '* ')):
|
|
109
|
+
list_items = []
|
|
110
|
+
while i < len(lines):
|
|
111
|
+
curr = lines[i].strip()
|
|
112
|
+
if curr.startswith(('- ', '* ')):
|
|
113
|
+
list_items.append(f'<li>{curr[2:]}</li>')
|
|
114
|
+
i += 1
|
|
115
|
+
elif not curr:
|
|
116
|
+
i += 1
|
|
117
|
+
break
|
|
118
|
+
else:
|
|
119
|
+
break
|
|
120
|
+
result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
|
|
121
|
+
# Ordered lists
|
|
122
|
+
elif re.match(r'^\d+\.\s', stripped):
|
|
123
|
+
list_items = []
|
|
124
|
+
while i < len(lines):
|
|
125
|
+
curr = lines[i].strip()
|
|
126
|
+
if re.match(r'^\d+\.\s', curr):
|
|
127
|
+
clean_item = re.sub(r'^\d+\.\s', '', curr)
|
|
128
|
+
list_items.append(f'<li>{clean_item}</li>')
|
|
129
|
+
i += 1
|
|
130
|
+
elif not curr:
|
|
131
|
+
i += 1
|
|
132
|
+
break
|
|
133
|
+
else:
|
|
134
|
+
break
|
|
135
|
+
result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
|
|
136
|
+
# Empty line
|
|
137
|
+
elif not stripped:
|
|
138
|
+
result.append('<br>')
|
|
139
|
+
i += 1
|
|
140
|
+
# Regular text
|
|
141
|
+
else:
|
|
142
|
+
result.append(line)
|
|
143
|
+
i += 1
|
|
144
|
+
|
|
145
|
+
html = '\n'.join(result)
|
|
146
|
+
|
|
147
|
+
# Inline elements
|
|
148
|
+
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
|
|
149
|
+
html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
|
|
150
|
+
html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
|
|
151
|
+
html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
|
|
152
|
+
|
|
153
|
+
return html
|
|
154
|
+
|
|
155
|
+
def _render_dataframe_html(self, df) -> str:
|
|
156
|
+
"""Render pandas DataFrame as HTML table (internal helper)"""
|
|
157
|
+
# Use pandas to_html with custom styling
|
|
158
|
+
html = df.to_html(
|
|
159
|
+
index=True,
|
|
160
|
+
escape=True,
|
|
161
|
+
classes='dataframe',
|
|
162
|
+
border=0
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Add custom styling
|
|
166
|
+
styled_html = f'''
|
|
167
|
+
<div style="overflow-x: auto; margin: 1rem 0;">
|
|
168
|
+
<style>
|
|
169
|
+
.dataframe {{
|
|
170
|
+
border-collapse: collapse;
|
|
171
|
+
width: 100%;
|
|
172
|
+
font-size: 0.9rem;
|
|
173
|
+
}}
|
|
174
|
+
.dataframe th {{
|
|
175
|
+
background: var(--sl-color-primary-600);
|
|
176
|
+
color: white;
|
|
177
|
+
padding: 0.75rem;
|
|
178
|
+
text-align: left;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
}}
|
|
181
|
+
.dataframe td {{
|
|
182
|
+
padding: 0.5rem 0.75rem;
|
|
183
|
+
border-bottom: 1px solid var(--sl-color-neutral-200);
|
|
184
|
+
}}
|
|
185
|
+
.dataframe tr:hover {{
|
|
186
|
+
background: var(--sl-color-neutral-50);
|
|
187
|
+
}}
|
|
188
|
+
</style>
|
|
189
|
+
{html}
|
|
190
|
+
</div>
|
|
191
|
+
'''
|
|
192
|
+
return styled_html
|
|
193
|
+
|
|
194
|
+
def heading(self, text, level: int = 1, divider: bool = False):
|
|
195
|
+
"""Display heading (h1-h6)"""
|
|
196
|
+
from ..state import State
|
|
197
|
+
import html as html_lib
|
|
198
|
+
|
|
199
|
+
cid = self._get_next_cid("heading")
|
|
200
|
+
def builder():
|
|
201
|
+
token = rendering_ctx.set(cid)
|
|
202
|
+
if isinstance(text, State):
|
|
203
|
+
content = text.value
|
|
204
|
+
elif callable(text):
|
|
205
|
+
content = text()
|
|
206
|
+
else:
|
|
207
|
+
content = text
|
|
208
|
+
rendering_ctx.reset(token)
|
|
209
|
+
|
|
210
|
+
# XSS protection: escape content
|
|
211
|
+
escaped_content = html_lib.escape(str(content))
|
|
212
|
+
|
|
213
|
+
grad = "gradient-text" if level == 1 else ""
|
|
214
|
+
html_output = f'<h{level} class="{grad}">{escaped_content}</h{level}>'
|
|
215
|
+
if divider: html_output += '<sl-divider class="divider"></sl-divider>'
|
|
216
|
+
return Component("div", id=cid, content=html_output)
|
|
217
|
+
self._register_component(cid, builder)
|
|
218
|
+
|
|
219
|
+
def title(self, text: Union[str, Callable]):
|
|
220
|
+
"""Display title (h1 with gradient)"""
|
|
221
|
+
self.heading(text, level=1, divider=False)
|
|
222
|
+
|
|
223
|
+
def header(self, text: Union[str, Callable], divider: bool = True):
|
|
224
|
+
"""Display header (h2)"""
|
|
225
|
+
self.heading(text, level=2, divider=divider)
|
|
226
|
+
|
|
227
|
+
def subheader(self, text: Union[str, Callable], divider: bool = False):
|
|
228
|
+
"""Display subheader (h3)"""
|
|
229
|
+
self.heading(text, level=3, divider=divider)
|
|
230
|
+
|
|
231
|
+
def text(self, content, size: str = "medium", muted: bool = False):
|
|
232
|
+
"""Display text paragraph"""
|
|
233
|
+
from ..state import State
|
|
234
|
+
|
|
235
|
+
cid = self._get_next_cid("text")
|
|
236
|
+
def builder():
|
|
237
|
+
token = rendering_ctx.set(cid)
|
|
238
|
+
if isinstance(content, State):
|
|
239
|
+
val = content.value
|
|
240
|
+
elif callable(content):
|
|
241
|
+
val = content()
|
|
242
|
+
else:
|
|
243
|
+
val = content
|
|
244
|
+
rendering_ctx.reset(token)
|
|
245
|
+
cls = f"text-{size} {'text-muted' if muted else ''}"
|
|
246
|
+
# XSS protection: enable content escaping
|
|
247
|
+
return Component("p", id=cid, content=val, escape_content=True, class_=cls)
|
|
248
|
+
self._register_component(cid, builder)
|
|
249
|
+
|
|
250
|
+
def caption(self, text: Union[str, Callable]):
|
|
251
|
+
"""Display caption text (small, muted)"""
|
|
252
|
+
self.text(text, size="small", muted=True)
|
|
253
|
+
|
|
254
|
+
def markdown(self, text: Union[str, Callable], **props):
|
|
255
|
+
"""Display markdown-formatted text"""
|
|
256
|
+
cid = self._get_next_cid("markdown")
|
|
257
|
+
def builder():
|
|
258
|
+
token = rendering_ctx.set(cid)
|
|
259
|
+
content = text() if callable(text) else text
|
|
260
|
+
|
|
261
|
+
# Enhanced markdown conversion - line-by-line processing
|
|
262
|
+
import re
|
|
263
|
+
lines = content.split('\n')
|
|
264
|
+
result = []
|
|
265
|
+
i = 0
|
|
266
|
+
|
|
267
|
+
while i < len(lines):
|
|
268
|
+
line = lines[i]
|
|
269
|
+
stripped = line.strip()
|
|
270
|
+
|
|
271
|
+
# Headers
|
|
272
|
+
if stripped.startswith('### '):
|
|
273
|
+
result.append(f'<h3>{stripped[4:]}</h3>')
|
|
274
|
+
i += 1
|
|
275
|
+
elif stripped.startswith('## '):
|
|
276
|
+
result.append(f'<h2>{stripped[3:]}</h2>')
|
|
277
|
+
i += 1
|
|
278
|
+
elif stripped.startswith('# '):
|
|
279
|
+
result.append(f'<h1>{stripped[2:]}</h1>')
|
|
280
|
+
i += 1
|
|
281
|
+
# Unordered lists
|
|
282
|
+
elif stripped.startswith(('- ', '* ')):
|
|
283
|
+
list_items = []
|
|
284
|
+
while i < len(lines):
|
|
285
|
+
curr = lines[i].strip()
|
|
286
|
+
if curr.startswith(('- ', '* ')):
|
|
287
|
+
list_items.append(f'<li>{curr[2:]}</li>')
|
|
288
|
+
i += 1
|
|
289
|
+
elif not curr: # Empty line
|
|
290
|
+
i += 1
|
|
291
|
+
break
|
|
292
|
+
else:
|
|
293
|
+
break
|
|
294
|
+
result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
|
|
295
|
+
# Ordered lists
|
|
296
|
+
elif re.match(r'^\d+\.\s', stripped):
|
|
297
|
+
list_items = []
|
|
298
|
+
while i < len(lines):
|
|
299
|
+
curr = lines[i].strip()
|
|
300
|
+
if re.match(r'^\d+\.\s', curr):
|
|
301
|
+
clean_item = re.sub(r'^\d+\.\s', '', curr)
|
|
302
|
+
list_items.append(f'<li>{clean_item}</li>')
|
|
303
|
+
i += 1
|
|
304
|
+
elif not curr: # Empty line
|
|
305
|
+
i += 1
|
|
306
|
+
break
|
|
307
|
+
else:
|
|
308
|
+
break
|
|
309
|
+
result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
|
|
310
|
+
# Empty line
|
|
311
|
+
elif not stripped:
|
|
312
|
+
result.append('<br>')
|
|
313
|
+
i += 1
|
|
314
|
+
# Regular text
|
|
315
|
+
else:
|
|
316
|
+
result.append(line)
|
|
317
|
+
i += 1
|
|
318
|
+
|
|
319
|
+
html = '\n'.join(result)
|
|
320
|
+
|
|
321
|
+
# Inline elements (bold, italic, code, links)
|
|
322
|
+
# Bold **text** (before italic to avoid conflicts)
|
|
323
|
+
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
|
|
324
|
+
# Italic *text* (avoid matching list markers)
|
|
325
|
+
html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
|
|
326
|
+
# Code `text`
|
|
327
|
+
html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
|
|
328
|
+
# Links [text](url)
|
|
329
|
+
html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
|
|
330
|
+
|
|
331
|
+
rendering_ctx.reset(token)
|
|
332
|
+
return Component("div", id=cid, content=html, class_="markdown", **props)
|
|
333
|
+
self._register_component(cid, builder)
|
|
334
|
+
|
|
335
|
+
def html(self, html_content: Union[str, Callable], **props):
|
|
336
|
+
"""Display raw HTML content
|
|
337
|
+
|
|
338
|
+
Use this when you need to render HTML directly without markdown processing.
|
|
339
|
+
For markdown formatting, use app.markdown() instead.
|
|
340
|
+
|
|
341
|
+
Example:
|
|
342
|
+
app.html('<div class="custom">Hello</div>')
|
|
343
|
+
"""
|
|
344
|
+
cid = self._get_next_cid("html")
|
|
345
|
+
def builder():
|
|
346
|
+
token = rendering_ctx.set(cid)
|
|
347
|
+
content = html_content() if callable(html_content) else html_content
|
|
348
|
+
rendering_ctx.reset(token)
|
|
349
|
+
return Component("div", id=cid, content=content, **props)
|
|
350
|
+
self._register_component(cid, builder)
|
|
351
|
+
|
|
352
|
+
def code(self, code: Union[str, Callable], language: Optional[str] = None, **props):
|
|
353
|
+
"""Display code block with syntax highlighting"""
|
|
354
|
+
import html as html_lib
|
|
355
|
+
|
|
356
|
+
cid = self._get_next_cid("code")
|
|
357
|
+
def builder():
|
|
358
|
+
token = rendering_ctx.set(cid)
|
|
359
|
+
code_text = code() if callable(code) else code
|
|
360
|
+
rendering_ctx.reset(token)
|
|
361
|
+
|
|
362
|
+
# XSS protection: escape code content
|
|
363
|
+
escaped_code = html_lib.escape(str(code_text))
|
|
364
|
+
|
|
365
|
+
lang_class = f"language-{language}" if language else ""
|
|
366
|
+
html_output = f'''
|
|
367
|
+
<pre style="background:var(--sl-bg-card);padding:1rem;border-radius:0.5rem;border:1px solid var(--sl-border);overflow-x:auto;">
|
|
368
|
+
<code class="{lang_class}" style="color:var(--sl-text);font-family:monospace;">{escaped_code}</code>
|
|
369
|
+
</pre>
|
|
370
|
+
'''
|
|
371
|
+
return Component("div", id=cid, content=html_output, **props)
|
|
372
|
+
self._register_component(cid, builder)
|
|
373
|
+
|
|
374
|
+
def html(self, html_content: Union[str, Callable], **props):
|
|
375
|
+
"""Render raw HTML"""
|
|
376
|
+
cid = self._get_next_cid("html")
|
|
377
|
+
def builder():
|
|
378
|
+
token = rendering_ctx.set(cid)
|
|
379
|
+
content = html_content() if callable(html_content) else html_content
|
|
380
|
+
rendering_ctx.reset(token)
|
|
381
|
+
return Component("div", id=cid, content=content, **props)
|
|
382
|
+
self._register_component(cid, builder)
|
|
383
|
+
|
|
384
|
+
def divider(self):
|
|
385
|
+
"""Display horizontal divider"""
|
|
386
|
+
cid = self._get_next_cid("divider")
|
|
387
|
+
def builder():
|
|
388
|
+
return Component("sl-divider", id=cid, class_="divider")
|
|
389
|
+
self._register_component(cid, builder)
|
|
390
|
+
|
|
391
|
+
def success(self, body, icon="check-circle"):
|
|
392
|
+
"""Display success message"""
|
|
393
|
+
self._alert(body, "success", icon)
|
|
394
|
+
|
|
395
|
+
def info(self, body, icon="info-circle"):
|
|
396
|
+
"""Display info message"""
|
|
397
|
+
self._alert(body, "primary", icon)
|
|
398
|
+
|
|
399
|
+
def warning(self, body, icon="exclamation-triangle"):
|
|
400
|
+
"""Display warning message"""
|
|
401
|
+
self._alert(body, "warning", icon)
|
|
402
|
+
|
|
403
|
+
def error(self, body, icon="exclamation-octagon"):
|
|
404
|
+
"""Display error message"""
|
|
405
|
+
self._alert(body, "danger", icon)
|
|
406
|
+
|
|
407
|
+
def _alert(self, body, variant, icon_name):
|
|
408
|
+
cid = self._get_next_cid("alert")
|
|
409
|
+
def builder():
|
|
410
|
+
icon_html = f'<sl-icon slot="icon" name="{icon_name}"></sl-icon>'
|
|
411
|
+
html = f'<sl-alert variant="{variant}" open style="margin-bottom:1rem;">{icon_html}{body}</sl-alert>'
|
|
412
|
+
return Component("div", id=cid, content=html)
|
|
413
|
+
self._register_component(cid, builder)
|