Skip to content

Commit 5d82c39

Browse files
committed
macos 15 runner (#47)
Signed-off-by: Daniel Guns <danbguns@gmail.com>
1 parent cfa0ea1 commit 5d82c39

4 files changed

Lines changed: 209 additions & 58 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
asset_name: flux9s-macos-aarch64.tar.gz
6060
use_tar: true
6161

62-
- os: macos-13
62+
- os: macos-15-intel
6363
target: x86_64-apple-darwin
6464
artifact_name: flux9s
6565
asset_name: flux9s-macos-x86_64.tar.gz

src/tui/app.rs

Lines changed: 162 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/tui/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,9 @@ pub async fn run_tui(
277277
// Process watch events (non-blocking)
278278
// Update state from watch events
279279
let mut events_processed = 0;
280+
// Track resource type count to detect when header layout needs recalculation
281+
let resource_type_count_before = app.state().count_by_type().len();
282+
280283
while let Ok(event) = event_rx.try_recv() {
281284
events_processed += 1;
282285
match event {
@@ -317,6 +320,11 @@ pub async fn run_tui(
317320

318321
// Force a redraw if we processed events
319322
if events_processed > 0 {
323+
// Check if number of resource types changed (affects header layout)
324+
let resource_type_count_after = app.state().count_by_type().len();
325+
if resource_type_count_after != resource_type_count_before {
326+
app.notify_resource_types_changed();
327+
}
320328
terminal.draw(|f| app.render(f))?;
321329
}
322330
}

0 commit comments

Comments
 (0)