violit 0.0.4.post1__py3-none-any.whl → 0.0.6__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.
@@ -1,413 +1,458 @@
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, **kwargs):
10
+ """Magic write: displays arguments based on their type
11
+
12
+ Supported types:
13
+ - Strings, Numbers, State: Rendered as Markdown text
14
+ - Pandas DataFrame/Series: Rendered as interactive table
15
+ - Dict/List: Rendered as JSON tree
16
+ - Matplotlib/Plotly Figures: Rendered as charts
17
+ - Exceptions: Rendered as error trace
18
+ """
19
+ from ..state import State, ComputedState
20
+
21
+ # Buffer for text-like arguments
22
+ text_buffer = []
23
+
24
+ def flush_buffer():
25
+ if text_buffer:
26
+ self.markdown(*text_buffer)
27
+ text_buffer.clear()
28
+
29
+ for arg in args:
30
+ # Unwrap state for type checking ONLY
31
+ check_val = arg.value if isinstance(arg, (State, ComputedState)) else arg
32
+
33
+ # 1. Pandas DataFrame / Series
34
+ is_df = False
35
+ try:
36
+ import pandas as pd
37
+ if isinstance(check_val, (pd.DataFrame, pd.Series, pd.Index)):
38
+ is_df = True
39
+ except ImportError: pass
40
+
41
+ if is_df:
42
+ flush_buffer()
43
+ if hasattr(self, 'dataframe'):
44
+ self.dataframe(arg)
45
+ else:
46
+ self.markdown(str(arg))
47
+ continue
48
+
49
+ # 2. Matplotlib Figure
50
+ is_pyplot = False
51
+ try:
52
+ import matplotlib.figure
53
+ if isinstance(check_val, matplotlib.figure.Figure):
54
+ is_pyplot = True
55
+ except ImportError: pass
56
+
57
+ if is_pyplot:
58
+ flush_buffer()
59
+ if hasattr(self, 'pyplot'):
60
+ self.pyplot(arg)
61
+ continue
62
+
63
+ # 3. Plotly Figure
64
+ is_plotly = False
65
+ try:
66
+ if hasattr(check_val, 'to_plotly_json'):
67
+ is_plotly = True
68
+ except ImportError: pass
69
+
70
+ if is_plotly:
71
+ flush_buffer()
72
+ if hasattr(self, 'plotly_chart'):
73
+ self.plotly_chart(arg)
74
+ continue
75
+
76
+ # 4. Dict / List (JSON)
77
+ if isinstance(check_val, (dict, list)):
78
+ flush_buffer()
79
+ if hasattr(self, 'json'):
80
+ self.json(arg)
81
+ else:
82
+ # Fallback if json widget logic is missing for State
83
+ # But wait, we need to fix json widget too.
84
+ self.json(arg)
85
+ continue
86
+
87
+ # 5. Exception
88
+ if isinstance(check_val, Exception):
89
+ flush_buffer()
90
+ if hasattr(self, 'exception'):
91
+ self.exception(arg)
92
+ else:
93
+ self.error(str(arg))
94
+ continue
95
+
96
+ # Default: Text-like (str, int, float, State, ComputedState)
97
+ text_buffer.append(arg)
98
+
99
+ # Flush remaining text
100
+ flush_buffer()
101
+
102
+ def _render_markdown(self, text: str) -> str:
103
+ """Render markdown to HTML (internal helper)"""
104
+ import re
105
+ lines = text.split('\n')
106
+ result = []
107
+ i = 0
108
+
109
+ while i < len(lines):
110
+ line = lines[i]
111
+ stripped = line.strip()
112
+
113
+ # Headers
114
+ if stripped.startswith('### '):
115
+ result.append(f'<h3>{stripped[4:]}</h3>')
116
+ i += 1
117
+ elif stripped.startswith('## '):
118
+ result.append(f'<h2>{stripped[3:]}</h2>')
119
+ i += 1
120
+ elif stripped.startswith('# '):
121
+ result.append(f'<h1>{stripped[2:]}</h1>')
122
+ i += 1
123
+ # Unordered lists
124
+ elif stripped.startswith(('- ', '* ')):
125
+ list_items = []
126
+ while i < len(lines):
127
+ curr = lines[i].strip()
128
+ if curr.startswith(('- ', '* ')):
129
+ list_items.append(f'<li>{curr[2:]}</li>')
130
+ i += 1
131
+ elif not curr:
132
+ i += 1
133
+ break
134
+ else:
135
+ break
136
+ result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
137
+ # Ordered lists
138
+ elif re.match(r'^\d+\.\s', stripped):
139
+ list_items = []
140
+ while i < len(lines):
141
+ curr = lines[i].strip()
142
+ if re.match(r'^\d+\.\s', curr):
143
+ clean_item = re.sub(r'^\d+\.\s', '', curr)
144
+ list_items.append(f'<li>{clean_item}</li>')
145
+ i += 1
146
+ elif not curr:
147
+ i += 1
148
+ break
149
+ else:
150
+ break
151
+ result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
152
+ # Empty line
153
+ elif not stripped:
154
+ result.append('<br>')
155
+ i += 1
156
+ # Regular text
157
+ else:
158
+ result.append(line)
159
+ i += 1
160
+
161
+ html = '\n'.join(result)
162
+
163
+ # Inline elements
164
+ html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
165
+ html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
166
+ html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
167
+ html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
168
+
169
+ return html
170
+
171
+ def _render_dataframe_html(self, df) -> str:
172
+ """Render pandas DataFrame as HTML table (internal helper)"""
173
+ # Use pandas to_html with custom styling
174
+ html = df.to_html(
175
+ index=True,
176
+ escape=True,
177
+ classes='dataframe',
178
+ border=0
179
+ )
180
+
181
+ # Add custom styling
182
+ styled_html = f'''
183
+ <div style="overflow-x: auto; margin: 1rem 0;">
184
+ <style>
185
+ .dataframe {{
186
+ border-collapse: collapse;
187
+ width: 100%;
188
+ font-size: 0.9rem;
189
+ }}
190
+ .dataframe th {{
191
+ background: var(--sl-color-primary-600);
192
+ color: white;
193
+ padding: 0.75rem;
194
+ text-align: left;
195
+ font-weight: 600;
196
+ }}
197
+ .dataframe td {{
198
+ padding: 0.5rem 0.75rem;
199
+ border-bottom: 1px solid var(--sl-color-neutral-200);
200
+ }}
201
+ .dataframe tr:hover {{
202
+ background: var(--sl-color-neutral-50);
203
+ }}
204
+ </style>
205
+ {html}
206
+ </div>
207
+ '''
208
+ return styled_html
209
+
210
+ def heading(self, *args, level: int = 1, divider: bool = False):
211
+ """Display heading (h1-h6)"""
212
+ from ..state import State, ComputedState
213
+ import html as html_lib
214
+
215
+ cid = self._get_next_cid("heading")
216
+ def builder():
217
+ token = rendering_ctx.set(cid)
218
+
219
+ parts = []
220
+ for arg in args:
221
+ if isinstance(arg, (State, ComputedState)):
222
+ parts.append(str(arg.value))
223
+ elif callable(arg):
224
+ parts.append(str(arg()))
225
+ else:
226
+ parts.append(str(arg))
227
+
228
+ content = " ".join(parts)
229
+ rendering_ctx.reset(token)
230
+
231
+ # XSS protection: escape content
232
+ escaped_content = html_lib.escape(str(content))
233
+
234
+ grad = "gradient-text" if level == 1 else ""
235
+ html_output = f'<h{level} class="{grad}">{escaped_content}</h{level}>'
236
+ if divider: html_output += '<sl-divider class="divider"></sl-divider>'
237
+ return Component("div", id=cid, content=html_output)
238
+ self._register_component(cid, builder)
239
+
240
+ def title(self, *args):
241
+ """Display title (h1 with gradient)"""
242
+ self.heading(*args, level=1, divider=False)
243
+
244
+ def header(self, *args, divider: bool = True):
245
+ """Display header (h2)"""
246
+ self.heading(*args, level=2, divider=divider)
247
+
248
+ def subheader(self, *args, divider: bool = False):
249
+ """Display subheader (h3)"""
250
+ self.heading(*args, level=3, divider=divider)
251
+
252
+ def text(self, *args, size: str = "medium", muted: bool = False):
253
+ """Display text paragraph
254
+
255
+ Supports multiple arguments which will be joined by spaces.
256
+ """
257
+ from ..state import State, ComputedState
258
+
259
+ cid = self._get_next_cid("text")
260
+ def builder():
261
+ token = rendering_ctx.set(cid)
262
+
263
+ parts = []
264
+ for arg in args:
265
+ if isinstance(arg, (State, ComputedState)):
266
+ parts.append(str(arg.value))
267
+ elif callable(arg):
268
+ parts.append(str(arg()))
269
+ else:
270
+ parts.append(str(arg))
271
+
272
+ val = " ".join(parts)
273
+ rendering_ctx.reset(token)
274
+
275
+ cls = f"text-{size} {'text-muted' if muted else ''}"
276
+ # XSS protection: enable content escaping
277
+ return Component("p", id=cid, content=val, escape_content=True, class_=cls)
278
+ self._register_component(cid, builder)
279
+
280
+ def caption(self, *args):
281
+ """Display caption text (small, muted)"""
282
+ self.text(*args, size="small", muted=True)
283
+
284
+ def markdown(self, *args, **props):
285
+ """Display markdown-formatted text
286
+
287
+ Supports multiple arguments which will be joined by spaces.
288
+ """
289
+ cid = self._get_next_cid("markdown")
290
+ def builder():
291
+ token = rendering_ctx.set(cid)
292
+ from ..state import State, ComputedState
293
+
294
+ parts = []
295
+ for arg in args:
296
+ if isinstance(arg, (State, ComputedState)):
297
+ parts.append(str(arg.value))
298
+ elif callable(arg):
299
+ parts.append(str(arg()))
300
+ else:
301
+ parts.append(str(arg))
302
+
303
+ content = " ".join(parts)
304
+
305
+ # Enhanced markdown conversion - line-by-line processing
306
+ import re
307
+ lines = content.split('\n')
308
+ result = []
309
+ i = 0
310
+
311
+ while i < len(lines):
312
+ line = lines[i]
313
+ stripped = line.strip()
314
+
315
+ # Headers
316
+ if stripped.startswith('### '):
317
+ result.append(f'<h3>{stripped[4:]}</h3>')
318
+ i += 1
319
+ elif stripped.startswith('## '):
320
+ result.append(f'<h2>{stripped[3:]}</h2>')
321
+ i += 1
322
+ elif stripped.startswith('# '):
323
+ result.append(f'<h1>{stripped[2:]}</h1>')
324
+ i += 1
325
+ # Unordered lists
326
+ elif stripped.startswith(('- ', '* ')):
327
+ list_items = []
328
+ while i < len(lines):
329
+ curr = lines[i].strip()
330
+ if curr.startswith(('- ', '* ')):
331
+ list_items.append(f'<li>{curr[2:]}</li>')
332
+ i += 1
333
+ elif not curr: # Empty line
334
+ i += 1
335
+ break
336
+ else:
337
+ break
338
+ result.append('<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ul>')
339
+ # Ordered lists
340
+ elif re.match(r'^\d+\.\s', stripped):
341
+ list_items = []
342
+ while i < len(lines):
343
+ curr = lines[i].strip()
344
+ if re.match(r'^\d+\.\s', curr):
345
+ clean_item = re.sub(r'^\d+\.\s', '', curr)
346
+ list_items.append(f'<li>{clean_item}</li>')
347
+ i += 1
348
+ elif not curr: # Empty line
349
+ i += 1
350
+ break
351
+ else:
352
+ break
353
+ result.append('<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">' + ''.join(list_items) + '</ol>')
354
+ # Empty line
355
+ elif not stripped:
356
+ result.append('<br>')
357
+ i += 1
358
+ # Regular text
359
+ else:
360
+ result.append(line)
361
+ i += 1
362
+
363
+ html = '\n'.join(result)
364
+
365
+ # Inline elements (bold, italic, code, links)
366
+ # Bold **text** (before italic to avoid conflicts)
367
+ html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
368
+ # Italic *text* (avoid matching list markers)
369
+ html = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<em>\1</em>', html)
370
+ # Code `text`
371
+ html = re.sub(r'`(.+?)`', r'<code style="background:var(--sl-bg-card);padding:0.2em 0.4em;border-radius:3px;">\1</code>', html)
372
+ # Links [text](url)
373
+ html = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2" style="color:var(--sl-primary);">\1</a>', html)
374
+
375
+ rendering_ctx.reset(token)
376
+ return Component("div", id=cid, content=html, class_="markdown", **props)
377
+ self._register_component(cid, builder)
378
+
379
+ def html(self, *args, **props):
380
+ """Display raw HTML content
381
+
382
+ Use this when you need to render HTML directly without markdown processing.
383
+ For markdown formatting, use app.markdown() instead.
384
+
385
+ Example:
386
+ app.html('<div class="custom">', count, '</div>')
387
+ """
388
+ cid = self._get_next_cid("html")
389
+ def builder():
390
+ from ..state import State, ComputedState
391
+ token = rendering_ctx.set(cid)
392
+
393
+ parts = []
394
+ for arg in args:
395
+ if isinstance(arg, (State, ComputedState)):
396
+ parts.append(str(arg.value))
397
+ elif callable(arg):
398
+ parts.append(str(arg()))
399
+ else:
400
+ parts.append(str(arg))
401
+
402
+ content = " ".join(parts)
403
+ rendering_ctx.reset(token)
404
+ return Component("div", id=cid, content=content, **props)
405
+ self._register_component(cid, builder)
406
+
407
+ def code(self, code: Union[str, Callable], language: Optional[str] = None, **props):
408
+ """Display code block with syntax highlighting"""
409
+ import html as html_lib
410
+
411
+ cid = self._get_next_cid("code")
412
+ def builder():
413
+ token = rendering_ctx.set(cid)
414
+ code_text = code() if callable(code) else code
415
+ rendering_ctx.reset(token)
416
+
417
+ # XSS protection: escape code content
418
+ escaped_code = html_lib.escape(str(code_text))
419
+
420
+ lang_class = f"language-{language}" if language else ""
421
+ html_output = f'''
422
+ <pre style="background:var(--sl-bg-card);padding:1rem;border-radius:0.5rem;border:1px solid var(--sl-border);overflow-x:auto;">
423
+ <code class="{lang_class}" style="color:var(--sl-text);font-family:monospace;">{escaped_code}</code>
424
+ </pre>
425
+ '''
426
+ return Component("div", id=cid, content=html_output, **props)
427
+ self._register_component(cid, builder)
428
+
429
+ def divider(self):
430
+ """Display horizontal divider"""
431
+ cid = self._get_next_cid("divider")
432
+ def builder():
433
+ return Component("sl-divider", id=cid, class_="divider")
434
+ self._register_component(cid, builder)
435
+
436
+ def success(self, body, icon="check-circle"):
437
+ """Display success message"""
438
+ self._alert(body, "success", icon)
439
+
440
+ def info(self, body, icon="info-circle"):
441
+ """Display info message"""
442
+ self._alert(body, "primary", icon)
443
+
444
+ def warning(self, body, icon="exclamation-triangle"):
445
+ """Display warning message"""
446
+ self._alert(body, "warning", icon)
447
+
448
+ def error(self, body, icon="exclamation-octagon"):
449
+ """Display error message"""
450
+ self._alert(body, "danger", icon)
451
+
452
+ def _alert(self, body, variant, icon_name):
453
+ cid = self._get_next_cid("alert")
454
+ def builder():
455
+ icon_html = f'<sl-icon slot="icon" name="{icon_name}"></sl-icon>'
456
+ html = f'<sl-alert variant="{variant}" open style="margin-bottom:1rem;">{icon_html}{body}</sl-alert>'
457
+ return Component("div", id=cid, content=html)
458
+ self._register_component(cid, builder)