Skip to content

Commit 179148c

Browse files
committed
fix(tui): consolidated TUI fixes for overflow, positioning, and panic prevention
This PR consolidates the following fixes: - #38: Prevent usize to u16 overflow in interactive renderer - #42: Prevent usize to u16 overflow in card count displays - #58: Fix cursor positioning and underflow in selection list - #59: Fix mention popup positioning and Unicode width calculation - #60: Improve autocomplete popup positioning and width calculation - #64: Prevent underflow in dropdown navigation and scroll calculations - #66: Prevent panic in HelpBrowserState when sections empty All changes target the TUI components to improve robustness: - Added saturating casts for u16 conversions - Fixed cursor positioning calculations - Added bounds checking for empty sections - Improved Unicode width handling for popups
1 parent c398212 commit 179148c

File tree

13 files changed

+82
-35
lines changed

13 files changed

+82
-35
lines changed

src/cortex-tui-components/src/dropdown.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ impl DropdownState {
9999

100100
/// Select the next item.
101101
pub fn select_next(&mut self) {
102-
if self.items.is_empty() {
102+
if self.items.is_empty() || self.max_visible == 0 {
103103
return;
104104
}
105105
self.selected = (self.selected + 1) % self.items.len();
@@ -108,7 +108,7 @@ impl DropdownState {
108108

109109
/// Select the previous item.
110110
pub fn select_prev(&mut self) {
111-
if self.items.is_empty() {
111+
if self.items.is_empty() || self.max_visible == 0 {
112112
return;
113113
}
114114
self.selected = if self.selected == 0 {

src/cortex-tui-components/src/scroll.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,16 @@ impl ScrollState {
119119
///
120120
/// Adjusts offset if necessary to make the item visible.
121121
pub fn ensure_visible(&mut self, index: usize) {
122+
// Guard against zero visible items to prevent underflow
123+
if self.visible == 0 {
124+
return;
125+
}
122126
if index < self.offset {
123127
// Item is above visible area - scroll up
124128
self.offset = index;
125129
} else if index >= self.offset + self.visible {
126130
// Item is below visible area - scroll down
127-
self.offset = index.saturating_sub(self.visible - 1);
131+
self.offset = index.saturating_sub(self.visible.saturating_sub(1));
128132
}
129133
self.clamp_offset();
130134
}

src/cortex-tui-components/src/selection_list.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ impl SelectionList {
572572
&& let Some(reason) = &item.disabled_reason
573573
{
574574
let reason_str = format!(" {}", reason);
575-
let reason_x = x + width - reason_str.len() as u16 - 1;
575+
let reason_x = x.saturating_add(width.saturating_sub(reason_str.len() as u16 + 1));
576576
if reason_x > col + 2 {
577577
buf.set_string(
578578
reason_x,
@@ -651,7 +651,11 @@ impl SelectionList {
651651

652652
buf.set_string(x + 2, area.y, &display_text, text_style);
653653

654-
let cursor_x = x + 2 + self.search_query.len() as u16;
654+
// Use character count for cursor position, and account for truncation
655+
let query_char_count = self.search_query.chars().count();
656+
let display_char_count = display_text.chars().count();
657+
let cursor_offset = display_char_count.min(query_char_count) as u16;
658+
let cursor_x = x + 2 + cursor_offset;
655659
if cursor_x < area.right().saturating_sub(1) {
656660
buf[(cursor_x, area.y)].set_bg(self.colors.accent);
657661
buf[(cursor_x, area.y)].set_fg(self.colors.void);

src/cortex-tui/src/cards/commands.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,9 @@ impl CardView for CommandsCard {
225225

226226
fn desired_height(&self, max_height: u16, _width: u16) -> u16 {
227227
// Base height for list items + search bar + some padding
228-
let command_count = self.commands.len() as u16;
229-
let content_height = command_count + 2; // +2 for search bar and padding
228+
// Use saturating conversion to prevent overflow when count > u16::MAX
229+
let command_count = u16::try_from(self.commands.len()).unwrap_or(u16::MAX);
230+
let content_height = command_count.saturating_add(2); // +2 for search bar and padding
230231

231232
// Clamp between min 5 and max 14, respecting max_height
232233
content_height.clamp(5, 14).min(max_height)

src/cortex-tui/src/cards/models.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ impl CardView for ModelsCard {
147147

148148
fn desired_height(&self, max_height: u16, _width: u16) -> u16 {
149149
// Base height for list items + search bar + some padding
150-
let model_count = self.models.len() as u16;
151-
let content_height = model_count + 2; // +2 for search bar and padding
150+
// Use saturating conversion to prevent overflow when count > u16::MAX
151+
let model_count = u16::try_from(self.models.len()).unwrap_or(u16::MAX);
152+
let content_height = model_count.saturating_add(2); // +2 for search bar and padding
152153

153154
// Clamp between min 5 and max 12, respecting max_height
154155
content_height.clamp(5, 12).min(max_height)

src/cortex-tui/src/cards/sessions.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ impl CardView for SessionsCard {
207207

208208
fn desired_height(&self, max_height: u16, _width: u16) -> u16 {
209209
// Base height: sessions + header + search bar + padding
210-
let content_height = self.sessions.len() as u16 + 3;
210+
// Use saturating conversion to prevent overflow when count > u16::MAX
211+
let session_count = u16::try_from(self.sessions.len()).unwrap_or(u16::MAX);
212+
let content_height = session_count.saturating_add(3);
211213
let min_height = 5;
212214
let max_desired = 15;
213215
content_height

src/cortex-tui/src/interactive/renderer.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,13 @@ impl<'a> InteractiveWidget<'a> {
109109
let hints_height = 1;
110110
let border_height = 2;
111111

112-
(items_count as u16) + header_height + search_height + hints_height + border_height
112+
// Use saturating conversion to prevent overflow when items_count exceeds u16::MAX
113+
let items_height = u16::try_from(items_count).unwrap_or(u16::MAX);
114+
items_height
115+
.saturating_add(header_height)
116+
.saturating_add(search_height)
117+
.saturating_add(hints_height)
118+
.saturating_add(border_height)
113119
}
114120
}
115121

src/cortex-tui/src/widgets/autocomplete.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,11 @@ impl<'a> AutocompletePopup<'a> {
7777
let item_count = self.state.visible_items().len() as u16;
7878
let height = item_count * ITEM_HEIGHT + 2; // +2 for borders
7979

80-
// Calculate width based on content
80+
// Calculate width based on visible/filtered items only (not all items)
81+
// This prevents the popup from being too wide when the filtered list is smaller
8182
let content_width = self
8283
.state
83-
.items
84+
.visible_items()
8485
.iter()
8586
.map(|item| {
8687
let icon_width = if item.icon != '\0' { 2 } else { 0 };
@@ -204,11 +205,19 @@ impl Widget for AutocompletePopup<'_> {
204205

205206
let (width, height) = self.calculate_dimensions();
206207

207-
// Position the popup above the input area
208-
// We assume `area` is positioned where the popup should appear
208+
// Position the popup above the input area if there's room, otherwise below
209+
// This prevents the popup from going off-screen at the top
210+
let y = if area.y >= height {
211+
// Enough room above - position popup above the input
212+
area.y.saturating_sub(height)
213+
} else {
214+
// Not enough room above - position popup below the input
215+
area.bottom()
216+
};
217+
209218
let popup_area = Rect {
210219
x: area.x,
211-
y: area.y.saturating_sub(height),
220+
y,
212221
width: width.min(area.width),
213222
height,
214223
};

src/cortex-tui/src/widgets/help_browser/render.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ impl<'a> HelpBrowser<'a> {
152152

153153
/// Renders the content pane.
154154
fn render_content(&self, area: Rect, buf: &mut Buffer) {
155-
let section = self.state.current_section();
155+
let Some(section) = self.state.current_section() else {
156+
return;
157+
};
156158
let mut y = area.y;
157159
let scroll = self.state.content_scroll;
158160
let mut line_idx = 0;

src/cortex-tui/src/widgets/help_browser/state.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,13 @@ impl HelpBrowserState {
148148
}
149149

150150
/// Returns the currently selected section.
151-
pub fn current_section(&self) -> &HelpSection {
152-
&self.sections[self.selected_section]
151+
///
152+
/// Returns `None` if the sections vector is empty.
153+
pub fn current_section(&self) -> Option<&HelpSection> {
154+
if self.sections.is_empty() {
155+
return None;
156+
}
157+
self.sections.get(self.selected_section)
153158
}
154159

155160
/// Handles character input for search.

0 commit comments

Comments
 (0)