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

View File

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

View File

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