fix: fix border style for BottomPane (#893)

This PR fixes things so that:

* when the `BottomPane` is in the `StatusIndicator` state, the border
should be dim
* when the `BottomPane` does not have input focus, the border should be
dim

To make it easier to enforce this invariant, this PR introduces
`BottomPane::set_state()` that will:

* update `self.state`
* call `update_border_for_input_focus()`
* request a repaint

This should make it easier to enforce other updates for state changes
going forward.
This commit is contained in:
Michael Bolin
2025-05-10 23:34:13 -07:00
committed by GitHub
parent b4785b5f88
commit a1f51bf91b
3 changed files with 63 additions and 45 deletions

View File

@@ -79,7 +79,7 @@ pub(crate) struct BottomPaneParams {
pub(crate) has_input_focus: bool, pub(crate) has_input_focus: bool,
} }
impl BottomPane<'_> { impl<'a> BottomPane<'a> {
pub fn new( pub fn new(
BottomPaneParams { BottomPaneParams {
app_event_tx, app_event_tx,
@@ -89,11 +89,12 @@ impl BottomPane<'_> {
let mut textarea = TextArea::default(); let mut textarea = TextArea::default();
textarea.set_placeholder_text("send a message"); textarea.set_placeholder_text("send a message");
textarea.set_cursor_line_style(Style::default()); textarea.set_cursor_line_style(Style::default());
update_border_for_input_focus(&mut textarea, has_input_focus); let state = PaneState::TextInput;
update_border_for_input_focus(&mut textarea, &state, has_input_focus);
Self { Self {
textarea, textarea,
state: PaneState::TextInput, state,
app_event_tx, app_event_tx,
has_input_focus, has_input_focus,
is_task_running: false, is_task_running: false,
@@ -112,7 +113,7 @@ impl BottomPane<'_> {
pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) { pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) {
self.has_input_focus = has_input_focus; self.has_input_focus = has_input_focus;
update_border_for_input_focus(&mut self.textarea, has_input_focus); update_border_for_input_focus(&mut self.textarea, &self.state, has_input_focus);
} }
/// Forward a key event to the appropriate child widget. /// Forward a key event to the appropriate child widget.
@@ -144,14 +145,14 @@ impl BottomPane<'_> {
text_rows as u16 + TEXTAREA_BORDER_LINES text_rows as u16 + TEXTAREA_BORDER_LINES
}; };
self.state = PaneState::StatusIndicator { self.set_state(PaneState::StatusIndicator {
view: StatusIndicatorWidget::new( view: StatusIndicatorWidget::new(
self.app_event_tx.clone(), self.app_event_tx.clone(),
desired_height, desired_height,
), ),
}; })?;
} else { } else {
self.state = PaneState::TextInput; self.set_state(PaneState::TextInput)?;
} }
} }
@@ -191,13 +192,13 @@ impl BottomPane<'_> {
match self.state { match self.state {
PaneState::TextInput => { PaneState::TextInput => {
if is_task_running { if is_task_running {
self.state = PaneState::StatusIndicator { self.set_state(PaneState::StatusIndicator {
view: StatusIndicatorWidget::new(self.app_event_tx.clone(), { view: StatusIndicatorWidget::new(self.app_event_tx.clone(), {
let text_rows = let text_rows =
self.textarea.lines().len().max(MIN_TEXTAREA_ROWS) as u16; self.textarea.lines().len().max(MIN_TEXTAREA_ROWS) as u16;
text_rows + TEXTAREA_BORDER_LINES text_rows + TEXTAREA_BORDER_LINES
}), }),
}; })?;
} else { } else {
return Ok(()); return Ok(());
} }
@@ -206,7 +207,7 @@ impl BottomPane<'_> {
if is_task_running { if is_task_running {
return Ok(()); return Ok(());
} else { } else {
self.state = PaneState::TextInput; self.set_state(PaneState::TextInput)?;
} }
} }
PaneState::ApprovalModal { .. } => { PaneState::ApprovalModal { .. } => {
@@ -220,35 +221,37 @@ impl BottomPane<'_> {
} }
/// Enqueue a new approval request coming from the agent. /// Enqueue a new approval request coming from the agent.
/// pub fn push_approval_request(
/// Returns `true` when this is the *first* modal - in that case the caller &mut self,
/// should trigger a redraw so that the modal becomes visible. request: ApprovalRequest,
pub fn push_approval_request(&mut self, request: ApprovalRequest) -> bool { ) -> Result<(), SendError<AppEvent>> {
let widget = UserApprovalWidget::new(request, self.app_event_tx.clone()); let widget = UserApprovalWidget::new(request, self.app_event_tx.clone());
match &mut self.state { match &mut self.state {
PaneState::StatusIndicator { .. } => { PaneState::StatusIndicator { .. } => self.set_state(PaneState::ApprovalModal {
self.state = PaneState::ApprovalModal { current: widget,
current: widget, queue: Vec::new(),
queue: Vec::new(), }),
};
true // Needs redraw so the modal appears.
}
PaneState::TextInput => { PaneState::TextInput => {
// Transition to modal state with an empty queue. // Transition to modal state with an empty queue.
self.state = PaneState::ApprovalModal { self.set_state(PaneState::ApprovalModal {
current: widget, current: widget,
queue: Vec::new(), queue: Vec::new(),
}; })
true // Needs redraw so the modal appears.
} }
PaneState::ApprovalModal { queue, .. } => { PaneState::ApprovalModal { queue, .. } => {
queue.push(widget); queue.push(widget);
false // Already in modal mode - no redraw required. Ok(())
} }
} }
} }
fn set_state(&mut self, state: PaneState<'a>) -> Result<(), SendError<AppEvent>> {
self.state = state;
update_border_for_input_focus(&mut self.textarea, &self.state, self.has_input_focus);
self.request_redraw()
}
fn request_redraw(&self) -> Result<(), SendError<AppEvent>> { fn request_redraw(&self) -> Result<(), SendError<AppEvent>> {
self.app_event_tx.send(AppEvent::Redraw) self.app_event_tx.send(AppEvent::Redraw)
} }
@@ -277,21 +280,40 @@ impl WidgetRef for &BottomPane<'_> {
} }
} }
fn update_border_for_input_focus(textarea: &mut TextArea, has_input_focus: bool) { // Note this sets the border for the TextArea, but the TextArea is not visible
let (title, border_style) = if has_input_focus { // for all variants of PaneState.
( fn update_border_for_input_focus(textarea: &mut TextArea, state: &PaneState, has_focus: bool) {
"use Enter to send for now (CtrlD to quit)", struct BlockState {
Style::default().dim(), title: &'static str,
) right_title: Line<'static>,
} else { border_style: Style,
("", Style::default()) }
};
let right_title = if has_input_focus { let accepting_input = match state {
Line::from("press enter to send").alignment(Alignment::Right) PaneState::TextInput => true,
} else { PaneState::ApprovalModal { .. } => true,
Line::from("") PaneState::StatusIndicator { .. } => false,
}; };
let block_state = if has_focus && accepting_input {
BlockState {
title: "use Enter to send for now (Ctrl-D to quit)",
right_title: Line::from("press enter to send").alignment(Alignment::Right),
border_style: Style::default(),
}
} else {
BlockState {
title: "",
right_title: Line::from(""),
border_style: Style::default().dim(),
}
};
let BlockState {
title,
right_title,
border_style,
} = block_state;
textarea.set_block( textarea.set_block(
ratatui::widgets::Block::default() ratatui::widgets::Block::default()
.title_bottom(title) .title_bottom(title)

View File

@@ -252,10 +252,7 @@ impl ChatWidget<'_> {
cwd, cwd,
reason, reason,
}; };
let needs_redraw = self.bottom_pane.push_approval_request(request); self.bottom_pane.push_approval_request(request)?;
if needs_redraw {
self.request_redraw()?;
}
} }
EventMsg::ApplyPatchApprovalRequest { EventMsg::ApplyPatchApprovalRequest {
changes, changes,
@@ -284,8 +281,7 @@ impl ChatWidget<'_> {
reason, reason,
grant_root, grant_root,
}; };
let _needs_redraw = self.bottom_pane.push_approval_request(request); self.bottom_pane.push_approval_request(request)?;
// Redraw is always need because the history has changed.
self.request_redraw()?; self.request_redraw()?;
} }
EventMsg::ExecCommandBegin { EventMsg::ExecCommandBegin {

View File

@@ -120,7 +120,7 @@ impl WidgetRef for StatusIndicatorWidget {
.padding(Padding::new(1, 0, 0, 0)) .padding(Padding::new(1, 0, 0, 0))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(widget_style); .border_style(widget_style.dim());
// Animated 3dot pattern inside brackets. The *active* dot is bold // Animated 3dot pattern inside brackets. The *active* dot is bold
// white, the others are dim. // white, the others are dim.
const DOT_COUNT: usize = 3; const DOT_COUNT: usize = 3;