@@ -69,6 +69,10 @@ pub struct App {
6969 status_message : Option < ( String , bool ) > , // (message, is_error)
7070 theme : Theme ,
7171 config : crate :: config:: Config , // Application configuration
72+ // Cached layout dimensions to prevent bouncing/flickering
73+ cached_terminal_size : Option < ( u16 , u16 ) > , // (width, height)
74+ cached_header_height : u16 ,
75+ cached_footer_height : u16 ,
7276}
7377
7478#[ derive( Clone , Copy , PartialEq ) ]
@@ -128,9 +132,25 @@ impl App {
128132 status_message : None ,
129133 theme,
130134 config,
135+ // Initialize layout cache - will be populated on first render
136+ cached_terminal_size : None ,
137+ cached_header_height : 7 , // Default minimum
138+ cached_footer_height : 3 , // Default minimum
131139 }
132140 }
133141
142+ /// Invalidate the cached layout dimensions, forcing recalculation on next render.
143+ /// Call this when filter state or resource counts change (anything that affects header height).
144+ fn invalidate_layout_cache ( & mut self ) {
145+ self . cached_terminal_size = None ;
146+ }
147+
148+ /// Public method to invalidate layout cache when resource types change.
149+ /// Should be called from the main event loop when watch events add new resource types.
150+ pub fn notify_resource_types_changed ( & mut self ) {
151+ self . invalidate_layout_cache ( ) ;
152+ }
153+
134154 /// Change theme by name
135155 pub fn set_theme ( & mut self , theme_name : & str ) -> Result < ( ) > {
136156 let theme = crate :: config:: ThemeLoader :: load_theme ( theme_name) ?;
@@ -488,6 +508,7 @@ impl App {
488508 // Enter filter mode
489509 self . filter_mode = true ;
490510 self . filter . clear ( ) ;
511+ self . invalidate_layout_cache ( ) ; // Filter state affects header height
491512 }
492513 crossterm:: event:: KeyCode :: Char ( 'y' ) => {
493514 // View YAML - trigger async fetch
@@ -550,24 +571,42 @@ impl App {
550571 crossterm:: event:: KeyCode :: Esc => {
551572 // Exit filter mode
552573 self . filter_mode = false ;
574+ let was_filtering = !self . filter . is_empty ( ) ;
553575 self . filter . clear ( ) ;
576+ if was_filtering {
577+ self . invalidate_layout_cache ( ) ; // Filter state affects header height
578+ }
554579 None
555580 }
556581 crossterm:: event:: KeyCode :: Enter => {
557582 // Apply filter and exit filter mode
558583 self . filter_mode = false ;
559584 self . selected_index = 0 ;
560585 self . scroll_offset = 0 ;
586+ // Only invalidate if filter was applied (non-empty) - this is when header changes
587+ if !self . filter . is_empty ( ) {
588+ self . invalidate_layout_cache ( ) ;
589+ }
561590 None
562591 }
563592 crossterm:: event:: KeyCode :: Backspace => {
593+ let was_empty = self . filter . is_empty ( ) ;
564594 self . filter . pop ( ) ;
595+ // Invalidate when transitioning from non-empty to empty (header line change)
596+ if !was_empty && self . filter . is_empty ( ) {
597+ self . invalidate_layout_cache ( ) ;
598+ }
565599 None
566600 }
567601 crossterm:: event:: KeyCode :: Char ( c) => {
602+ let was_empty = self . filter . is_empty ( ) ;
568603 self . filter . push ( c) ;
569604 self . selected_index = 0 ;
570605 self . scroll_offset = 0 ;
606+ // Invalidate when transitioning from empty to non-empty (header line change)
607+ if was_empty {
608+ self . invalidate_layout_cache ( ) ;
609+ }
571610 None
572611 }
573612 _ => None ,
@@ -935,14 +974,18 @@ impl App {
935974
936975 // Use registry for resource type command mapping
937976 if cmd_lower == "all" || cmd_lower == "clear" {
938- self . selected_resource_type = None ;
977+ if self . selected_resource_type . is_some ( ) {
978+ self . selected_resource_type = None ;
979+ self . invalidate_layout_cache ( ) ; // Resource type filter affects header display
980+ }
939981 return None ;
940982 }
941983
942984 if let Some ( display_name) = crate :: watcher:: get_display_name_for_command ( & cmd_lower) {
943985 self . selected_resource_type = Some ( display_name. to_string ( ) ) ;
944986 self . selected_index = 0 ;
945987 self . scroll_offset = 0 ;
988+ self . invalidate_layout_cache ( ) ; // Resource type filter affects header display
946989 }
947990
948991 None
@@ -1001,62 +1044,128 @@ impl App {
10011044 }
10021045 }
10031046
1004- // Calculate header height dynamically based on resource type wrapping
1005- // Filter info is now on its own line when active, so add 1 line if filtering
1006- // Ensure header is tall enough for ASCII art (5 lines) + borders + padding
1007- let base_height = 3 ; // Context line, Flux9s line, at least one resource line
1008- let filter_line = if !self . filter . is_empty ( ) || self . selected_resource_type . is_some ( ) {
1009- 1
1010- } else {
1011- 0
1012- } ;
1013- let resource_type_lines = {
1014- let counts = self . state . count_by_type ( ) ;
1015- let available_width = ( f. size ( ) . width * 70 / 100 ) . saturating_sub ( 12 ) ;
1016- let mut lines = 1 ;
1017- let mut current_len = 11 ; // "Resources: "
1018- for ( rt, count) in counts. iter ( ) {
1019- let part = format ! ( "{}:{} " , rt, count) ;
1020- if current_len + part. len ( ) > available_width as usize && current_len > 11 {
1021- lines += 1 ;
1022- current_len = part. len ( ) ;
1047+ let terminal_width = f. size ( ) . width ;
1048+ let terminal_height = f. size ( ) . height ;
1049+ let current_size = ( terminal_width, terminal_height) ;
1050+
1051+ // Only recalculate layout dimensions when terminal size changes
1052+ // This prevents flickering/bouncing caused by per-frame recalculation
1053+ let size_changed = self . cached_terminal_size != Some ( current_size) ;
1054+ if size_changed {
1055+ self . cached_terminal_size = Some ( current_size) ;
1056+
1057+ // Calculate header height using EXACT same logic as header.rs
1058+ // header.rs uses: left_area.width.saturating_sub(12) where left_area is 70% of total
1059+ // We need to match this exactly to prevent mismatched wrapping calculations
1060+ let header_left_width = {
1061+ // Layout::split with Percentage(70) gives floor(width * 70 / 100)
1062+ // but we need to account for potential rounding - use the same method
1063+ let header_chunks = Layout :: default ( )
1064+ . direction ( Direction :: Horizontal )
1065+ . constraints ( [ Constraint :: Percentage ( 70 ) , Constraint :: Percentage ( 30 ) ] )
1066+ . split ( Rect :: new ( 0 , 0 , terminal_width, 1 ) ) ;
1067+ header_chunks[ 0 ] . width
1068+ } ;
1069+ let available_width_for_resources = header_left_width. saturating_sub ( 12 ) ;
1070+
1071+ // Header content lines:
1072+ // 1. Context line (Context: xxx Namespace: xxx)
1073+ // 2. Flux9s | Total line
1074+ // 3+ Resource type lines (variable based on wrapping)
1075+ // +1 if filter is active (filter status line)
1076+ // Plus 2 for borders
1077+ let base_content_lines: u16 = 2 ; // Context line + Flux9s/Total line
1078+ let filter_line: u16 =
1079+ if !self . filter . is_empty ( ) || self . selected_resource_type . is_some ( ) {
1080+ 1
10231081 } else {
1024- current_len += part. len ( ) ;
1082+ 0
1083+ } ;
1084+
1085+ let resource_type_lines: u16 = {
1086+ let counts = self . state . count_by_type ( ) ;
1087+ if counts. is_empty ( ) {
1088+ 1 // At least one line for "no resources"
1089+ } else {
1090+ let mut lines: u16 = 1 ;
1091+ let mut current_len: usize = 11 ; // "Resources: " prefix
1092+
1093+ // Sort counts to match header.rs rendering order (alphabetical)
1094+ let mut type_counts: Vec < _ > = counts. iter ( ) . collect ( ) ;
1095+ type_counts. sort_by_key ( |( resource_type, _) | * resource_type) ;
1096+
1097+ for ( rt, count) in type_counts. iter ( ) {
1098+ let part = format ! ( "{}:{} " , rt, count) ;
1099+ // Match header.rs wrapping logic exactly (line 77-78)
1100+ if current_len + part. len ( ) > available_width_for_resources as usize
1101+ && current_len > 11
1102+ {
1103+ lines += 1 ;
1104+ current_len = part. len ( ) ;
1105+ } else {
1106+ current_len += part. len ( ) ;
1107+ }
1108+ }
1109+ lines
10251110 }
1111+ } ;
1112+
1113+ // Total content lines + 2 for borders
1114+ let content_lines = base_content_lines + filter_line + resource_type_lines;
1115+ // Minimum height for ASCII art (5 lines) + 2 borders = 7
1116+ let min_header_height: u16 = 7 ;
1117+ self . cached_header_height = ( content_lines + 2 ) . max ( min_header_height) ;
1118+
1119+ // Calculate footer height using EXACT same logic as footer.rs
1120+ // footer.rs nav_segments match these entries exactly
1121+ let nav_segments: & [ ( & str , & str ) ] = & [
1122+ ( "j/k " , "Navigate" ) ,
1123+ ( ":" , "Command" ) ,
1124+ ( "Enter" , "Details" ) ,
1125+ ( "y" , "YAML" ) ,
1126+ ( "t" , "Trace" ) ,
1127+ ( "s" , "Suspend" ) ,
1128+ ( "r" , "Resume" ) ,
1129+ ( "R" , "Reconcile" ) ,
1130+ ( "W" , "Reconcile+Source" ) ,
1131+ ( "d" , "Delete" ) ,
1132+ ( "/" , "Filter(Name)" ) ,
1133+ ( "?" , "Help" ) ,
1134+ ( "Esc" , "Back/Quit" ) ,
1135+ ] ;
1136+
1137+ let footer_available_width = terminal_width. saturating_sub ( 2 ) ; // Account for borders
1138+
1139+ // Calculate exact total length (matching footer.rs:190-198)
1140+ let mut total_length: usize = 0 ;
1141+ for ( idx, ( key, label) ) in nav_segments. iter ( ) . enumerate ( ) {
1142+ let separator_len = if idx > 0 { 3 } else { 0 } ; // " | "
1143+ let segment_len = if * key == "j/k " {
1144+ key. len ( ) + label. len ( )
1145+ } else {
1146+ key. len ( ) + 1 + label. len ( ) // key + space + label
1147+ } ;
1148+ total_length += separator_len + segment_len;
10261149 }
1027- lines
1028- } ;
1029- // Ensure header is at least tall enough for ASCII art (5 lines) + borders
1030- let min_header_height = 7 ; // 5 ASCII lines + 2 borders
1031- let header_height =
1032- ( base_height + filter_line + resource_type_lines) . max ( min_header_height) ;
1033-
1034- // Calculate footer height dynamically - footer can be 1-2 lines
1035- // We need to calculate this before rendering to prevent bouncing
1036- let footer_height = {
1037- let available_width = f. size ( ) . width . saturating_sub ( 2 ) ;
1038- // Calculate if footer would wrap (simplified calculation)
1039- // Navigation segments: j/k Navigate, : Command, Enter Details, y YAML, s Suspend, r Resume, d Delete, R Reconcile, / Filter(Name), ? Help, Esc Back/Quit
1040- let nav_segments_count = 11 ;
1041- let estimated_chars_per_segment = 12 ; // Average chars per segment including key and label
1042- let estimated_separators = ( nav_segments_count - 1 ) * 3 ; // " | " separators
1043- let estimated_total =
1044- ( nav_segments_count * estimated_chars_per_segment) + estimated_separators;
1045- if estimated_total > available_width as usize {
1046- 2 // Would wrap to 2 lines
1150+
1151+ // Calculate lines needed (matching footer.rs:201-205)
1152+ let footer_content_lines: u16 = if footer_available_width > 0 {
1153+ ( ( total_length as f32 ) / ( footer_available_width as f32 ) ) . ceil ( ) as u16
10471154 } else {
1048- 1 // Single line
1049- }
1050- } ;
1155+ 1
1156+ } ;
10511157
1052- // Ensure we have minimum terminal size - if too small, show error message
1053- let terminal_height = f. size ( ) . height ;
1054- let terminal_width = f. size ( ) . width ;
1055- let footer_constraint = footer_height + 2 ; // Footer content + borders
1158+ self . cached_footer_height = footer_content_lines. max ( 1 ) + 2 ; // Content + borders
1159+ }
1160+
1161+ let header_height = self . cached_header_height ;
1162+ let footer_constraint = self . cached_footer_height ;
1163+
1164+ // Ensure we have minimum terminal size
10561165 let min_height = header_height + footer_constraint + 3 ; // header + footer + min content
1057- let min_width = 80 ;
1166+ let min_width: u16 = 80 ;
10581167
1059- if terminal_height < min_height as u16 || terminal_width < min_width {
1168+ if terminal_height < min_height || terminal_width < min_width {
10601169 // Terminal too small - show error
10611170 let error_msg = format ! (
10621171 "Terminal too small! Need at least {}x{} (current: {}x{})" ,
@@ -1079,10 +1188,10 @@ impl App {
10791188 if self . config . ui . headless {
10801189 Constraint :: Length ( 0 ) // No header in headless mode
10811190 } else {
1082- Constraint :: Length ( header_height as u16 ) // Dynamic header height
1191+ Constraint :: Length ( header_height) // Cached header height
10831192 } ,
1084- Constraint :: Min ( 0 ) , // Main content (flexible)
1085- Constraint :: Length ( footer_constraint as u16 ) , // Footer (content + borders)
1193+ Constraint :: Min ( 0 ) , // Main content (flexible)
1194+ Constraint :: Length ( footer_constraint) , // Cached footer height
10861195 ] )
10871196 . split ( f. size ( ) ) ;
10881197
0 commit comments