Skip to content

Commit 7b24bde

Browse files
committed
feat(gui): enhance markdown parsing and header styling
- Add new text tags for italicized headers (h1_italic to h4_italic) to improve UI rendering - Implement utility functions _strip_inline_formatting and _strip_formatting_simple to handle markdown markers - Add _extract_tables function to identify and replace markdown tables with placeholders - Update typing imports to include List support in utils.py
1 parent 20f64b2 commit 7b24bde

File tree

3 files changed

+277
-15
lines changed

3 files changed

+277
-15
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## [4.1.0] - 2026-02-09
4+
5+
### New Features
6+
7+
- **Audio Attachments**: Added support for attaching and playing audio files (MP3, WAV, OGG) directly in the chat window.
8+
- **Markdown Rendering**: Enhanced markdown parsing with support for italicized headers and improved table handling.
9+
10+
### Improvements
11+
12+
- **Workspace**: Improved robustness of frozen state detection to better handle Nuitka and standalone executable environments.
13+
14+
### Build & Deployment
15+
16+
- **Launchers**: Replaced C# launchers with Python-based cx_Freeze implementations to eliminate AV false positives and to simplify and centralize logi.
17+
318
## [4.0.1] - 2026-02-08
419

520
### New Features/Changes

src/gui/utils.py

Lines changed: 231 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import webbrowser
1717
import tkinter as tk
1818
from tkinter import font as tkfont
19-
from typing import Optional, Dict, Union, Tuple
19+
from typing import Optional, Dict, Union, Tuple, List
2020

2121
# Windows-specific imports and constants
2222
_user32 = None
@@ -200,7 +200,28 @@ def setup_text_tags(text_widget: tk.Text, colors: Union[Dict[str, str], ThemeCol
200200
font=(base_font, 11, "bold"),
201201
foreground=colors["fg"],
202202
spacing1=3, spacing3=2)
203-
203+
204+
# Header + italic combinations
205+
text_widget.tag_configure("h1_italic",
206+
font=(base_font, 16, "bold italic"),
207+
foreground=colors["header1"],
208+
spacing1=6, spacing3=4)
209+
210+
text_widget.tag_configure("h2_italic",
211+
font=(base_font, 14, "bold italic"),
212+
foreground=colors["header2"],
213+
spacing1=5, spacing3=3)
214+
215+
text_widget.tag_configure("h3_italic",
216+
font=(base_font, 12, "bold italic"),
217+
foreground=colors["header3"],
218+
spacing1=4, spacing3=2)
219+
220+
text_widget.tag_configure("h4_italic",
221+
font=(base_font, 11, "bold italic"),
222+
foreground=colors["fg"],
223+
spacing1=3, spacing3=2)
224+
204225
# Inline formatting
205226
text_widget.tag_configure("bold", font=(base_font, 11, "bold"))
206227
text_widget.tag_configure("italic", font=(base_font, 11, "italic"))
@@ -325,6 +346,190 @@ def setup_text_tags(text_widget: tk.Text, colors: Union[Dict[str, str], ThemeCol
325346
spacing1=1, spacing3=2)
326347

327348

349+
def _strip_inline_formatting(text: str) -> Tuple[str, Optional[str]]:
350+
"""
351+
Remove markdown inline formatting markers and detect the formatting style.
352+
353+
Returns:
354+
Tuple of (stripped_text, format_style) where format_style is one of:
355+
'bold_italic', 'bold', 'italic', or None
356+
"""
357+
# Check for bold+italic first
358+
match = re.match(r'^\*\*\*(.+)\*\*\*$', text.strip())
359+
if match:
360+
return match.group(1), 'bold_italic'
361+
362+
match = re.match(r'^___(.+)___$', text.strip())
363+
if match:
364+
return match.group(1), 'bold_italic'
365+
366+
# Check for bold
367+
match = re.match(r'^\*\*(.+)\*\*$', text.strip())
368+
if match:
369+
return match.group(1), 'bold'
370+
371+
match = re.match(r'^__(.+)__$', text.strip())
372+
if match:
373+
return match.group(1), 'bold'
374+
375+
# Check for italic
376+
match = re.match(r'^\*([^\*]+)\*$', text.strip())
377+
if match:
378+
return match.group(1), 'italic'
379+
380+
match = re.match(r'^_([^_]+)_$', text.strip())
381+
if match:
382+
return match.group(1), 'italic'
383+
384+
# No formatting or mixed - strip all markers
385+
result = text
386+
result = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', result)
387+
result = re.sub(r'\*\*(.+?)\*\*', r'\1', result)
388+
result = re.sub(r'__(.+?)__', r'\1', result)
389+
result = re.sub(r'\*([^\*]+)\*', r'\1', result)
390+
result = re.sub(r'`([^`]+)`', r'\1', result)
391+
return result, None
392+
393+
394+
def _strip_formatting_simple(text: str) -> str:
395+
"""Strip inline formatting markers without detecting style (for tables)."""
396+
text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text)
397+
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
398+
text = re.sub(r'__(.+?)__', r'\1', text)
399+
text = re.sub(r'\*([^\*]+)\*', r'\1', text)
400+
text = re.sub(r'`([^`]+)`', r'\1', text)
401+
return text
402+
403+
404+
def _extract_tables(text: str) -> Tuple[str, List[Tuple[int, List[List[str]]]]]:
405+
"""
406+
Extract markdown tables from text and replace with placeholders.
407+
408+
Returns:
409+
Tuple of (modified_text, list of (placeholder_line_index, table_data))
410+
"""
411+
lines = text.split('\n')
412+
result_lines = []
413+
tables = []
414+
i = 0
415+
416+
while i < len(lines):
417+
line = lines[i]
418+
stripped = line.strip()
419+
420+
# Check if this looks like a table row (starts and ends with |)
421+
if stripped.startswith('|') and stripped.endswith('|'):
422+
table_rows = []
423+
table_start = len(result_lines)
424+
425+
# Collect all consecutive table rows
426+
while i < len(lines):
427+
row_line = lines[i].strip()
428+
if not (row_line.startswith('|') and row_line.endswith('|')):
429+
break
430+
431+
# Parse cells (split by | and strip)
432+
cells = [c.strip() for c in row_line.split('|')[1:-1]]
433+
434+
# Skip separator rows (containing only dashes/colons)
435+
if cells and all(re.match(r'^:?-+:?$', c) for c in cells):
436+
i += 1
437+
continue
438+
439+
table_rows.append(cells)
440+
i += 1
441+
442+
if table_rows:
443+
# Add placeholder and store table data
444+
placeholder = f"__TABLE_PLACEHOLDER_{len(tables)}__"
445+
result_lines.append(placeholder)
446+
tables.append((table_start, table_rows))
447+
continue
448+
449+
result_lines.append(line)
450+
i += 1
451+
452+
return '\n'.join(result_lines), tables
453+
454+
455+
def _render_table(text_widget: tk.Text, table_data: List[List[str]], colors: Dict[str, str],
456+
role_tag: Optional[str] = None, block_tag: Optional[str] = None,
457+
line_prefix: str = ""):
458+
"""Render a markdown table to the text widget with proper box-drawing borders."""
459+
if not table_data:
460+
return
461+
462+
def build_tags(*primary_tags):
463+
result = list(primary_tags)
464+
if role_tag:
465+
result.append(role_tag)
466+
if block_tag:
467+
result.append(block_tag)
468+
return tuple(result) if result else ("normal",)
469+
470+
# Calculate column widths
471+
num_cols = max(len(row) for row in table_data)
472+
col_widths = [0] * num_cols
473+
474+
for row in table_data:
475+
for j, cell in enumerate(row):
476+
if j < num_cols:
477+
col_widths[j] = max(col_widths[j], len(_strip_formatting_simple(cell)))
478+
479+
# Ensure minimum column width
480+
col_widths = [max(w, 3) for w in col_widths]
481+
482+
# Box-drawing characters
483+
# ┌─┬─┐ top border
484+
# │ │ │ row with data
485+
# ├─┼─┤ separator (after header)
486+
# │ │ │ row with data
487+
# └─┴─┘ bottom border
488+
489+
# Build top border: ┌───┬───┬───┐
490+
top_parts = ["─" * (w + 2) for w in col_widths]
491+
top_border = "┌" + "┬".join(top_parts) + "┐"
492+
493+
# Build header separator: ├───┼───┼───┤
494+
mid_parts = ["─" * (w + 2) for w in col_widths]
495+
mid_border = "├" + "┼".join(mid_parts) + "┤"
496+
497+
# Build bottom border: └───┴───┴───┘
498+
bottom_parts = ["─" * (w + 2) for w in col_widths]
499+
bottom_border = "└" + "┴".join(bottom_parts) + "┘"
500+
501+
# Insert top border
502+
text_widget.insert(tk.END, line_prefix, build_tags("normal"))
503+
text_widget.insert(tk.END, top_border + "\n", build_tags("codeblock"))
504+
505+
# Render each row
506+
for row_idx, row in enumerate(table_data):
507+
# Pad row to have correct number of columns
508+
while len(row) < num_cols:
509+
row.append("")
510+
511+
# Build row: │ cell1 │ cell2 │ cell3 │
512+
row_parts = []
513+
for col_idx, cell in enumerate(row):
514+
cell_text = _strip_formatting_simple(cell)
515+
padded = cell_text.ljust(col_widths[col_idx])
516+
row_parts.append(f" {padded} ")
517+
518+
row_text = "│" + "│".join(row_parts) + "│"
519+
520+
text_widget.insert(tk.END, line_prefix, build_tags("normal"))
521+
text_widget.insert(tk.END, row_text + "\n", build_tags("codeblock"))
522+
523+
# Add separator after header row
524+
if row_idx == 0:
525+
text_widget.insert(tk.END, line_prefix, build_tags("normal"))
526+
text_widget.insert(tk.END, mid_border + "\n", build_tags("codeblock"))
527+
528+
# Insert bottom border
529+
text_widget.insert(tk.END, line_prefix, build_tags("normal"))
530+
text_widget.insert(tk.END, bottom_border + "\n", build_tags("codeblock"))
531+
532+
328533
def render_markdown(text: str, text_widget: tk.Text, colors: Dict[str, str],
329534
wrap: bool = True, as_role: Optional[str] = None,
330535
enable_emojis: bool = True, block_tag: Optional[str] = None,
@@ -344,10 +549,13 @@ def render_markdown(text: str, text_widget: tk.Text, colors: Dict[str, str],
344549
"""
345550
# Setup tags if not already done
346551
setup_text_tags(text_widget, colors)
347-
552+
348553
# Configure wrap mode
349554
text_widget.configure(wrap=tk.WORD if wrap else tk.NONE)
350-
555+
556+
# Pre-process: Extract and render tables first
557+
text, table_blocks = _extract_tables(text)
558+
351559
lines = text.split('\n')
352560
in_code_block = False
353561
code_block_lines = []
@@ -404,6 +612,15 @@ def build_tags(*primary_tags):
404612
if not stripped:
405613
continue
406614

615+
# Check for table placeholder
616+
table_match = re.match(r'^__TABLE_PLACEHOLDER_(\d+)__$', stripped)
617+
if table_match:
618+
table_idx = int(table_match.group(1))
619+
if table_idx < len(table_blocks):
620+
_, table_data = table_blocks[table_idx]
621+
_render_table(text_widget, table_data, colors, role_tag, block_tag, line_prefix)
622+
continue
623+
407624
# Insert prefix for this line
408625
if line_prefix:
409626
prefix_tags = build_tags("normal")
@@ -419,8 +636,16 @@ def build_tags(*primary_tags):
419636
break
420637

421638
if level <= 6 and len(stripped) > level and stripped[level] == ' ':
422-
content = stripped[level+1:]
423-
tag = f"h{min(level, 4)}"
639+
header_text = stripped[level+1:]
640+
content, format_style = _strip_inline_formatting(header_text)
641+
base_tag = f"h{min(level, 4)}"
642+
643+
# Use italic header tag if italic formatting detected
644+
if format_style in ('italic', 'bold_italic'):
645+
tag = f"{base_tag}_italic"
646+
else:
647+
tag = base_tag
648+
424649
tags = build_tags(tag)
425650
_insert_with_emojis(text_widget, content, tags, enable_emojis)
426651
continue

0 commit comments

Comments
 (0)