use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::geometry::{Insets, Rect, Size};
use bmux_tui::paint::{LocalRect, PaintCx};
use bmux_tui_components::picker_frame::{
PickerFrame, PickerFrameComponent, PickerFramePolicy, PickerFrameStyles,
};
use bmux_tui_components::selectable_list::{SelectableListComponent, SelectableListStyles};
use super::composer_interaction::ComposerInteraction;
use super::render::TuiTheme;
pub fn paint(
state: &mut ComposerInteraction,
composer: Rect,
cx: &mut PaintCx<'_, '_>,
theme: TuiTheme,
) {
state.painted_list = None;
state.painted_layout = None;
if !state.visible() {
return;
}
let bounds = cx.clip();
let available = bounds;
let field = state.focused_field().map_or(composer, |field| field.anchor);
let field = Rect::new(
field.x.saturating_sub(bounds.x),
field.y.saturating_sub(bounds.y),
field.width,
field.height,
);
if available.is_empty() {
return;
}
state.completion_control.reconcile(&state.items);
let list_state = state.completion_control.list_state();
let list = SelectableListComponent::new("composer.list", &state.items, list_state).styles(
SelectableListStyles {
background: theme.raised,
normal: theme.raised,
focused: theme.raised.patch(theme.selection),
selected: theme.raised.patch(theme.selection),
..SelectableListStyles::default()
},
);
let title = state.title().to_owned();
let hint = state.hint().to_owned();
let has_hint = !hint.is_empty();
let shell = PickerFrame::new()
.title(&title)
.header(state.status())
.footer(hint)
.policy(PickerFramePolicy {
chrome: true,
background: true,
header: true,
input: false,
footer: has_hint,
margin: Insets::all(0),
padding: Insets::new(0, 1, 0, 1),
min_size: Size::new(0, 0),
max_size: Size::new(available.width.min(88), available.height.min(8)),
placement: bmux_tui_components::picker_frame::PickerFramePlacement::FieldAnchored {
field,
prefer_below: false,
},
})
.styles(PickerFrameStyles {
border: theme.raised.patch(theme.focused),
background: theme.raised,
header: theme.raised,
input: theme.raised,
list: theme.raised,
footer: theme.raised,
});
let component = PickerFrameComponent::new("composer", shell, list);
let layout = component.layout(Constraints::tight(available.size()), &mut LayoutCx::new());
cx.with_child(
i32::from(available.x),
i64::from(available.y),
LocalRect::new(0, 0, available.width, available.height),
|cx| component.paint(&layout, cx),
);
if let Some(panel) = layout.children.first()
&& let Some(slot) = panel
.node
.children
.iter()
.find(|child| child.node.id.as_str() == "composer.list")
&& let (Ok(x), Ok(y)) = (
u16::try_from(panel.x.saturating_add(slot.x)),
u16::try_from(panel.y.saturating_add(slot.y)),
)
{
let list_area = Rect::new(
available.x.saturating_add(x),
available.y.saturating_add(y),
u16::try_from(slot.node.size.width).unwrap_or(u16::MAX),
u16::try_from(slot.node.size.height).unwrap_or(u16::MAX),
)
.intersection(available);
if !list_area.is_empty() {
state.painted_layout = Some(slot.node.clone());
state.painted_list = Some(list_area);
}
}
state.list = list_state.get();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composer_interaction::tests::{begin, completion};
use bmux_text_edit::TextEditBuffer;
use bmux_tui::buffer::Buffer;
use bmux_tui::event::{Event, MouseEvent, MouseEventKind};
use bmux_tui::frame::Frame;
use bmux_tui::geometry::Point;
use bmux_tui_components::selectable_list::SelectableListOutcome;
#[test]
fn query_refresh_keeps_measured_popup_and_blocks_stale_acceptance() {
use bmux_tui_components::completion::CompletionCommand;
let mut state = ComposerInteraction::default();
let mut editor = TextEditBuffer::from_text("#topic");
let mut request = begin(&mut state, &editor);
assert!(state.complete(completion(&request)));
let scope = state_scope();
for text in ["#topics", "#topics🦀", "#topic", "#"] {
let previous = completion(&request);
let area = Rect::new(0, 0, 90, 18);
let mut pixels = Buffer::empty(area);
let mut frame = Frame::new(&mut pixels);
paint(
&mut state,
Rect::new(0, 16, 90, 2),
&mut PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
let geometry = state.painted_list.unwrap();
state.await_field_paint(&scope);
assert!(state.visible());
assert!(state.selected_edit().is_none());
assert!(!state.complete(previous));
let (_, accept) = state.command(CompletionCommand::Accept);
assert!(!accept);
editor = TextEditBuffer::from_text(text);
request = begin(&mut state, &editor);
assert!(state.visible());
assert!(state.selected_edit().is_none());
paint(
&mut state,
Rect::new(0, 16, 90, 2),
&mut PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
assert_eq!(state.painted_list, Some(geometry));
assert!(state.complete(completion(&request)));
assert!(state.selected_edit().is_some());
}
state.dismiss();
assert!(!state.visible());
assert!(state.selected_edit().is_none());
}
fn state_scope() -> super::super::composer_interaction::ComposerScope {
super::super::composer_interaction::tests::scope()
}
#[test]
fn unicode_picker_resize_clips_hits_and_clears_old_geometry() {
let mut state = ComposerInteraction::default();
let buffer = TextEditBuffer::from_text("#主题👩💻a\u{301}");
let request = begin(&mut state, &buffer);
let mut reply = completion(&request);
let candidate = &mut reply.snapshot.as_mut().unwrap().candidates[0];
candidate.label = "主题👩💻a\u{301}".into();
candidate.matched_ranges =
vec![bcode_plugin_sdk::composer::ComposerTextRange { start: 0, end: 6 }];
state.complete(reply);
assert!(state.items[0].lines[0].spans.iter().any(|span| {
span.content == "主题"
&& span
.style
.modifiers
.contains(bmux_tui::style::Modifier::BOLD)
}));
for (width, height) in [(90, 18), (0, 0), (1, 1), (2, 5), (5, 3), (24, 10), (90, 18)] {
let area = Rect::new(0, 0, width, height);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
paint(
&mut state,
Rect::new(0, height.saturating_sub(2), width, 2),
&mut PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
for hit in frame.hits().regions() {
assert!(hit.area.right() <= area.right());
assert!(hit.area.bottom() <= area.bottom());
assert!(!hit.area.is_empty());
}
if width == 0 || height == 1 {
assert!(state.painted_list.is_none());
}
if let Some(list) = state.painted_list {
assert!(list.right() <= area.right());
assert!(list.bottom() <= height.saturating_sub(2));
}
}
let event = Event::Mouse(MouseEvent::new(MouseEventKind::Move, Point::new(0, 17)));
assert_eq!(state.list_event(&event), SelectableListOutcome::Ignored);
state.dismiss();
assert!(state.painted_list.is_none());
assert_eq!(state.list_event(&event), SelectableListOutcome::Ignored);
}
#[test]
fn measured_list_navigation_and_dismissal_preserve_input_focus() {
let mut state = ComposerInteraction::default();
let buffer = TextEditBuffer::from_text("#topic");
let request = begin(&mut state, &buffer);
let mut reply = completion(&request);
let candidates = &mut reply.snapshot.as_mut().unwrap().candidates;
let mut second = candidates[0].clone();
second.id = "second".into();
second.label = "next 🦀".into();
candidates.push(second);
state.complete(reply);
let area = Rect::new(0, 0, 90, 18);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
paint(
&mut state,
Rect::new(0, 16, 90, 2),
&mut PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
let (outcome, accept) =
state.command(bmux_tui_components::completion::CompletionCommand::Next);
assert_eq!(outcome, bmux_tui::event::EventOutcome::Redraw);
assert!(!accept);
assert_eq!(state.list.focused(), Some(1));
assert_eq!(
state.list_event(&Event::Key(bmux_keyboard::parse_key_stroke("x").unwrap())),
SelectableListOutcome::Ignored
);
state.dismiss();
assert_eq!(
state.list_event(&Event::Key(
bmux_keyboard::parse_key_stroke("down").unwrap()
)),
SelectableListOutcome::Ignored
);
}
}