use bevy_app::{App, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{Component, Has, IntoScheduleConfigs, Local, Query, Res, With};
use bevy_ecs::system::SystemParam;
use bevy_input_focus::InputFocus;
use plurimus::core::ratatui_core::text::{Line, Span};
use plurimus::core::{TerminalSize, UiWidget};
use plurimus::ui::ModalOpen;
use plurimus::widgets::ratatui_widgets::paragraph::Paragraph;
use super::command;
use super::focus::Ring;
use super::layout::HintRow;
use super::scope::{KeyScope, Scoped};
use super::theme::{Repainted, Theme};
pub fn plugin(app: &mut App) {
app.add_systems(Update, refresh_hints.in_set(Repainted));
}
pub type Hint = (&'static str, &'static str);
#[derive(Component, Clone, Copy, Debug)]
pub struct Hints(pub &'static [Hint]);
const FINDERS: [Hint; 2] = [(":", "commands"), ("?", "help")];
const HINT_GAP: usize = 2;
const EDGE: usize = 1;
fn width_of(hints: &[Hint]) -> usize {
let said: usize = hints
.iter()
.map(|(key, word)| key.chars().count() + 1 + word.chars().count())
.sum();
said + HINT_GAP * hints.len().saturating_sub(1)
}
fn fitted(mut leading: Vec<Hint>, trailing: &[Hint], cols: usize) -> Vec<Hint> {
let reserved = width_of(trailing) + 2 * EDGE + HINT_GAP;
while !leading.is_empty() && width_of(&leading) + reserved > cols {
leading.pop();
}
leading
}
#[derive(SystemParam)]
struct Held<'w, 's> {
focus: Res<'w, InputFocus>,
parents: Query<'w, 's, &'static ChildOf>,
hinted: Query<'w, 's, (Option<&'static Hints>, Has<ModalOpen>)>,
scoped: Scoped<'w, 's>,
}
impl Held<'_, '_> {
fn hints(&self) -> (Vec<Hint>, bool) {
let mut found = Vec::new();
let Some(held) = self.focus.get() else {
return (found, false);
};
let owns_keys = self.scoped.of(held) == Some(KeyScope::All);
for entity in std::iter::once(held).chain(self.parents.iter_ancestors(held)) {
let Ok((hints, is_modal)) = self.hinted.get(entity) else {
continue;
};
found.extend(hints.into_iter().flat_map(|hints| hints.0));
if is_modal {
break;
}
}
(found, owns_keys)
}
}
#[derive(Default, PartialEq)]
struct Drawn {
leading: Vec<Hint>,
has_finders: bool,
cols: u16,
}
const WALK: Hint = ("⇥", "pane");
fn refresh_hints(
held: Held,
shell: (Ring, Res<TerminalSize>, Res<Theme>),
mut rows: Query<&mut UiWidget, With<HintRow>>,
mut drawn: Local<Drawn>,
mut has_many: Local<bool>,
) {
let (ring, size, theme) = shell;
if ring.shown.is_changed() {
*has_many = ring.has_many();
}
let (mut leading, owns_keys) = held.hints();
if !owns_keys {
if *has_many {
leading.push(WALK);
}
leading.extend(command::page_hints(ring.shown.surface()));
}
let trailing: &[Hint] = if owns_keys { &[] } else { &FINDERS };
let cols = usize::from(size.cols);
let wanted = Drawn {
leading: fitted(leading, trailing, cols),
has_finders: !owns_keys,
cols: size.cols,
};
if *drawn == wanted && !theme.is_changed() {
return;
}
let gap = cols.saturating_sub(width_of(&wanted.leading) + width_of(trailing) + 2 * EDGE);
let mut spans = vec![Span::raw(" ".repeat(EDGE))];
push_run(&mut spans, &wanted.leading, &theme);
spans.push(Span::raw(" ".repeat(gap)));
push_run(&mut spans, trailing, &theme);
for mut widget in &mut rows {
*widget = UiWidget::new(Paragraph::new(Line::from(spans.clone())));
}
*drawn = wanted;
}
fn push_run(spans: &mut Vec<Span<'static>>, hints: &[Hint], theme: &Theme) {
for (at, (key, word)) in hints.iter().enumerate() {
if at > 0 {
spans.push(Span::raw(" ".repeat(HINT_GAP)));
}
spans.push(Span::styled(*key, theme.accented()));
spans.push(Span::styled(format!(" {word}"), theme.dimmed()));
}
}
#[cfg(test)]
mod tests {
use super::*;
const LEADING: [Hint; 3] = [("⏎", "open"), ("a", "add"), ("ctrl-s", "save")];
#[test]
fn hints_that_fit_are_all_kept() {
assert_eq!(fitted(LEADING.to_vec(), &FINDERS, 80), LEADING);
}
#[test]
fn whole_hints_are_dropped_from_the_end_and_the_finders_never() {
let needed = width_of(&LEADING[..2]) + width_of(&FINDERS) + 2 * EDGE + HINT_GAP;
assert_eq!(fitted(LEADING.to_vec(), &FINDERS, needed), LEADING[..2]);
assert_eq!(fitted(LEADING.to_vec(), &FINDERS, needed - 1), LEADING[..1]);
assert!(fitted(LEADING.to_vec(), &FINDERS, 0).is_empty());
}
}