99from .models import Substate
1010
1111
12+ class CustomFooter (Horizontal ):
13+ """A custom footer with keyboard shortcuts and render ID."""
14+
15+ def __init__ (self , render_id : str = "" , ** kwargs ):
16+ super ().__init__ (** kwargs )
17+ self .render_id = render_id
18+
19+ def compose (self ):
20+ yield Static ("ctrl+c: quit * ctrl+l: toggle logs" , classes = "custom-footer-text" )
21+ if self .render_id :
22+ yield Static (f"render id: { self .render_id } " , classes = "custom-footer-render-id" )
23+
24+
1225class ScriptOutputType (str , Enum ):
1326 UNIT_TEST_OUTPUT_TEXT = "Latest unit test script execution output: "
1427 CONFORMANCE_TEST_OUTPUT_TEXT = "Latest conformance tests script execution output: "
@@ -229,74 +242,50 @@ def compose(self):
229242 )
230243
231244
232- class ClickableArrow (Static ):
233- """A clickable arrow widget for expanding/collapsing logs."""
234-
235- def __init__ (self , is_expanded : bool = False , ** kwargs ):
236- arrow = "▼" if is_expanded else "▶"
237- super ().__init__ (arrow , ** kwargs )
238- self .classes = "log-arrow"
239-
240- def on_click (self , event ):
241- """Notify parent to toggle expansion."""
242- # Bubble up to parent CollapsibleLogEntry
243- event .stop ()
244- parent = self .parent
245- if isinstance (parent , CollapsibleLogEntry ):
246- parent .toggle_expansion ()
247-
248-
249- class CollapsibleLogEntry (Horizontal ):
250- """A single collapsible log entry that can be clicked to expand/collapse."""
245+ class LogEntry (Horizontal ):
246+ """A single log entry displayed as a table row with 4 columns."""
251247
252248 def __init__ (self , logger_name : str , level : str , message : str , timestamp : str = "" , ** kwargs ):
253249 super ().__init__ (** kwargs )
254250 self .logger_name = logger_name
255251 self .level = level
256252 self .message = message
257253 self .timestamp = timestamp
258- self .is_expanded = False
259254 self .classes = f"log-entry log-{ level .lower ()} "
260255
261256 def compose (self ):
262- # Start with collapsed view - arrow and full message text side by side
263- yield ClickableArrow (id = "arrow" )
264- yield Static (self .message , classes = "log-summary" )
265-
266- def toggle_expansion (self ):
267- """Toggle the expansion state of this log entry."""
268- self .is_expanded = not self .is_expanded
269- self .call_later (self .refresh_display )
270-
271- async def refresh_display (self ):
272- """Update the display based on expanded state."""
273- # Remove all children
274- await self .remove_children ()
275-
276- if self .is_expanded :
277- # Expanded view: shows arrow, full message, and structured metadata
278- await self .mount (ClickableArrow (is_expanded = True ))
279- expanded_text = f"{ self .message } \n "
280- expanded_text += f" Level: { self .level } \n "
281- expanded_text += f" Location: { self .logger_name } \n "
282- expanded_text += f" Time: { self .timestamp } "
283- await self .mount (Static (expanded_text , classes = "log-expanded" ))
284- else :
285- # Collapsed view: shows arrow and full message only
286- await self .mount (ClickableArrow (is_expanded = False ))
287- await self .mount (Static (self .message , classes = "log-summary" ))
257+ # Column 1: Offset time with brackets (just the time portion, not full timestamp)
258+ time_part = self .timestamp .split ()[- 1 ] if self .timestamp else ""
259+ yield Static (f"[{ time_part } ]" , classes = "log-col-time" )
260+
261+ # Column 2: Level
262+ yield Static (self .level , classes = "log-col-level" )
263+
264+ # Column 3: Location (logger name) - truncate if longer than 20 characters
265+ location = self .logger_name
266+ if len (location ) > 9 :
267+ location = location [:9 ] + "..."
268+ yield Static (location , classes = "log-col-location" )
269+
270+ # Column 4: Message with green checkmark for successful outcomes
271+ message_text = self .message
272+ # Add green checkmark for messages indicating success
273+ if any (keyword in message_text .lower () for keyword in [
274+ "completed" , "success" , "successfully" , "passed" , "done" , "✓"
275+ ]):
276+ message_text = f"[green]✓[/green] { message_text } "
277+ yield Static (message_text , classes = "log-col-message" )
288278
289279
290280class StructuredLogView (VerticalScroll ):
291- """A scrollable container for collapsible log entries."""
281+ """A scrollable container for log entries displayed as a table ."""
292282
293283 # Log level hierarchy (lower number = lower priority)
294284 LOG_LEVELS = {
295285 "DEBUG" : 0 ,
296286 "INFO" : 1 ,
297287 "WARNING" : 2 ,
298288 "ERROR" : 3 ,
299- "CRITICAL" : 4 ,
300289 }
301290
302291 def __init__ (self , ** kwargs ):
@@ -311,7 +300,17 @@ def _should_show_log(self, level: str) -> bool:
311300
312301 async def add_log (self , logger_name : str , level : str , message : str , timestamp : str = "" ):
313302 """Add a new log entry."""
314- entry = CollapsibleLogEntry (logger_name , level , message , timestamp )
303+ # Check if this is a success message that should have spacing before it
304+ is_success_message = any (keyword in message .lower () for keyword in [
305+ "completed" , "success" , "successfully" , "passed" , "done" , "✓"
306+ ])
307+
308+ # Add empty line before success messages
309+ if is_success_message :
310+ spacer = Static ("" , classes = "log-spacer" )
311+ await self .mount (spacer )
312+
313+ entry = LogEntry (logger_name , level , message , timestamp )
315314
316315 # Only show if level is >= minimum level
317316 if not self ._should_show_log (level ):
@@ -326,7 +325,7 @@ def filter_logs(self, min_level: str):
326325 self .min_level = min_level
327326
328327 # Update visibility of all existing log entries
329- for entry in self .query (CollapsibleLogEntry ):
328+ for entry in self .query (LogEntry ):
330329 entry .display = self ._should_show_log (entry .level )
331330
332331
@@ -341,32 +340,55 @@ def __init__(self, min_level: str):
341340class LogLevelFilter (Horizontal ):
342341 """Filter logs by minimum level with buttons."""
343342
344- LEVELS = ["DEBUG" , "INFO" , "WARNING" , "ERROR" , "CRITICAL" ]
343+ LEVELS = ["debug" , "info" , "warning" , "error" ]
344+
345+ # Make the widget focusable to receive keyboard events
346+ can_focus = True
345347
346348 def __init__ (self , ** kwargs ):
347349 super ().__init__ (** kwargs )
348- self .current_level = "DEBUG"
350+ self .current_level = "INFO"
351+ self .current_index = self .LEVELS .index (self .current_level .lower ())
349352
350353 def compose (self ):
351- yield Static ("Level: " , classes = "filter-label" )
352- for level in self .LEVELS :
353- variant = "primary" if level == self .current_level else "default"
354- yield Button (level , id = f"filter-{ level .lower ()} " , variant = variant , classes = "filter-button" ) # type: ignore[arg-type]
354+ yield Static ("level: " , classes = "filter-label" )
355+ with Horizontal (classes = "filter-buttons-container" ):
356+ for level in self .LEVELS :
357+ variant = "primary" if level .upper () == self .current_level else "default"
358+ btn = Button (level .upper (), id = f"filter-{ level .lower ()} " , variant = variant , classes = "filter-button" ) # type: ignore[arg-type]
359+ btn .can_focus = False # Prevent buttons from receiving focus
360+ yield btn
361+
362+ def on_key (self , event ):
363+ """Handle tab key to cycle through levels."""
364+ if event .key == "tab" :
365+ # Move to next level
366+ self .current_index = (self .current_index + 1 ) % len (self .LEVELS )
367+ new_level = self .LEVELS [self .current_index ].upper ()
368+ self ._update_level (new_level )
369+ event .prevent_default ()
370+ event .stop ()
355371
356372 def on_button_pressed (self , event ):
357373 """Handle level button press."""
358374 # Extract level from button ID
359375 button_id = event .button .id
360376 if button_id and button_id .startswith ("filter-" ):
361377 level = button_id .replace ("filter-" , "" ).upper ()
362- self .current_level = level
378+ self .current_index = self .LEVELS .index (level .lower ())
379+ self ._update_level (level )
363380
364- # Update button variants
365- for btn in self .query (Button ):
366- if btn .id == f"filter-{ level .lower ()} " :
367- btn .variant = "primary"
368- else :
369- btn .variant = "default"
381+ def _update_level (self , level : str ):
382+ """Update the current level and button states."""
383+ self .current_level = level
384+
385+ # Update button variants
386+ for btn in self .query (Button ):
387+ if btn .id == f"filter-{ level .lower ()} " :
388+ btn .variant = "primary"
389+ else :
390+ btn .variant = "default"
391+ btn .refresh () # Force immediate visual update
370392
371- # Notify parent to refresh log visibility
372- self .post_message (LogFilterChanged (level ))
393+ # Notify parent to refresh log visibility
394+ self .post_message (LogFilterChanged (level ))
0 commit comments