1616import webbrowser
1717import tkinter as tk
1818from 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+
328533def 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