tuie: replace hand-rolled widgets with verbatim tuie-demo copies
14 widgets now match the demo source exactly (import paths rewritten): accordion, button, checkbox, counter, flat_button, focus_pane, global_chords, horizontal_rule, link, page_layout, point_picker, progress_bar, radio_group, segmented_control. Theme simplified: accent color is now Color::YELLOW, which apply_atmosphere resolves through harmonious to the agent's current primary — no thread-local, no RGB arithmetic. Added chord_macro + axis2d deps matching tuie-demo's Cargo.toml. All 27 broken call sites (field_grid, settings, cockpit, dropdown, model_picker, text_editor) migrated to demo APIs: FlatButton::new().child(x), Button::new().children([x]), Checkbox::new(label).set_checked(b), Counter::new(), PointPicker::new().point(...), etc.
This commit is contained in:
parent
a3a61b2634
commit
85aa1f77de
22 changed files with 266 additions and 392 deletions
|
|
@ -59,6 +59,8 @@ hound = "3.5"
|
|||
crossterm = { version = "0.28", features = ["bracketed-paste"] } # Terminal control (bracketed paste for paste detection) — kept for old TUI
|
||||
ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend — kept for old TUI
|
||||
tuie = { path = "../tuie", features = ["harmonious", "images"] } # New composable widget toolkit — replacing ratatui
|
||||
chord_macro = { path = "../tuie/chord_macro" } # chord!() input-matching macro — the one the tuie-demo widgets use
|
||||
axis2d = "0.1.0" # Axis2D/Vec2 geometry — re-exported by tuie, used directly by the demo widgets
|
||||
unicode-width = "0.1"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] } # Clipboard — click-to-copy a message bubble
|
||||
colored = "2" # Color gradients and effects
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ impl FieldGrid {
|
|||
let value_widget: Box<dyn Widget> = match value {
|
||||
EditableValue::Bool(b) => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = Checkbox::new(Text::new().content(""), accent)
|
||||
.with_checked(*b)
|
||||
.id(&mut id);
|
||||
let mut c = Checkbox::new(Text::new().content(""));
|
||||
c.set_checked(*b);
|
||||
let w = c.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
|
|
@ -100,23 +100,21 @@ impl FieldGrid {
|
|||
n if n > 6 => {
|
||||
let current = labels.get(*index).copied().unwrap_or("?");
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = FlatButton::new(accent)
|
||||
.children([
|
||||
Text::new().content(format!(" {current} \u{25be}")),
|
||||
])
|
||||
let w = FlatButton::new()
|
||||
.child(Text::new().content(format!(" {current} \u{25be}")))
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
5 | 6 => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = Dropdown::new(&labels, *index, accent).id(&mut id);
|
||||
let w = Dropdown::new(&labels, *index).id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
4 => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = RadioGroup::new(&labels, accent)
|
||||
let w = RadioGroup::new(&labels)
|
||||
.selected(*index)
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
|
|
@ -124,7 +122,7 @@ impl FieldGrid {
|
|||
}
|
||||
_ => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = SegmentedControl::new(&labels, accent)
|
||||
let w = SegmentedControl::new(&labels)
|
||||
.selected(*index)
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
|
|
@ -134,7 +132,7 @@ impl FieldGrid {
|
|||
}
|
||||
EditableValue::Uint(v) => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = Counter::new("", accent)
|
||||
let w = Counter::new("")
|
||||
.value(*v as i32)
|
||||
.min(0)
|
||||
.max(i32::MAX)
|
||||
|
|
@ -144,7 +142,7 @@ impl FieldGrid {
|
|||
}
|
||||
EditableValue::Int(v) => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = Counter::new("", accent).value(*v as i32).id(&mut id);
|
||||
let w = Counter::new("").value(*v as i32).id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
|
|
@ -157,8 +155,7 @@ impl FieldGrid {
|
|||
}
|
||||
EditableValue::Point2D { x, y } => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = PointPicker::new(accent)
|
||||
.grid(Vec2::new(5, 5))
|
||||
let w = PointPicker::new()
|
||||
.point(Vec2::new(*x as u16, *y as u16))
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
|
|
@ -171,24 +168,24 @@ impl FieldGrid {
|
|||
format!(" {s} ")
|
||||
};
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = FlatButton::new(accent)
|
||||
.children([Text::new().content(display)])
|
||||
let w = FlatButton::new()
|
||||
.child(Text::new().content(display))
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
EditableValue::Secret(_) => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = FlatButton::new(accent)
|
||||
.children([Text::new().content(" \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022} ")])
|
||||
let w = FlatButton::new()
|
||||
.child(Text::new().content(" \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022} "))
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
}
|
||||
EditableValue::OptionalText(None) => {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let w = FlatButton::new(accent)
|
||||
.children([Text::new().content(StyledStr::new(" (none) ").fg(dim))])
|
||||
let w = FlatButton::new()
|
||||
.child(Text::new().content(StyledStr::new(" (none) ").fg(dim)))
|
||||
.id(&mut id);
|
||||
widget_id = id.untyped();
|
||||
w as Box<dyn Widget>
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ impl SettingsScreen {
|
|||
let field_grid_pane = field_grid.widget().id(&mut wide_field_grid_id);
|
||||
|
||||
let cat_title = view.selected_category().label().to_string();
|
||||
let accordion = Accordion::new_expanded(&cat_title, field_grid_pane as Box<dyn Widget>, accent)
|
||||
let accordion = Accordion::new_expanded(&cat_title, field_grid_pane as Box<dyn Widget>)
|
||||
.id(&mut wide_accordion_id);
|
||||
|
||||
let main_page = Pane::new()
|
||||
|
|
@ -322,7 +322,7 @@ impl SettingsScreen {
|
|||
let mut narrow_page_layout_id = WidgetId::EMPTY;
|
||||
|
||||
let cat_labels: Vec<&str> = Category::all().iter().map(|c| c.label()).collect();
|
||||
let tabs = SegmentedControl::new(&cat_labels, accent)
|
||||
let tabs = SegmentedControl::new(&cat_labels)
|
||||
.id(&mut narrow_tab_id);
|
||||
|
||||
let narrow_fields = view.fields_for_category(view.selected_category());
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ impl ModelPicker {
|
|||
.placeholder(Text::new().content(StyledStr::new(" fuzzy search\u{2026} ").fg(dim)))
|
||||
.id(&mut input_id);
|
||||
|
||||
let refresh_button = Button::new(accent)
|
||||
let refresh_button = Button::new()
|
||||
.children([Text::new().content(StyledStr::new(" \u{21bb} ").fg(Color::BLACK).bold())])
|
||||
.id(&mut refresh_button_id);
|
||||
|
||||
|
|
|
|||
|
|
@ -72,11 +72,11 @@ impl TextEditor {
|
|||
.border_style(Style::new().fg(dim).dim())
|
||||
.children([input_widget as Box<dyn Widget>]);
|
||||
|
||||
let save_button = Button::new(accent)
|
||||
let save_button = Button::new()
|
||||
.children([Text::new().content(" Save ")])
|
||||
.id(&mut save_button_id);
|
||||
|
||||
let cancel_button = Button::new(dim)
|
||||
let cancel_button = Button::new()
|
||||
.children([Text::new().content(" Cancel ")])
|
||||
.id(&mut cancel_button_id);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
//! `Atmosphere`) onto tuie's Theme slots so the terminal palette tracks the
|
||||
//! agent's atmospheric shift — live, without restarting.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use tuie::prelude::{Color, Style};
|
||||
use tuie::theme::Theme;
|
||||
|
||||
|
|
@ -97,44 +95,22 @@ pub fn to_tuie_color(c: ratatui::style::Color) -> Color {
|
|||
}
|
||||
}
|
||||
|
||||
// ── User-selectable accent color ──────────────────────────────────────────
|
||||
// ── Accent color ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// The accent is not a separately-tracked value. `apply_atmosphere` feeds the
|
||||
// agent's ChatPalette through tuie's harmonious palette, where `agent_primary`
|
||||
// lands on the `yellow` theme slot (see `chat_palette_to_theme`). So the accent
|
||||
// is simply `Color::YELLOW`, which harmonious resolves to the agent's primary at
|
||||
// render time — tracking every atmosphere shift for free, no thread-local state.
|
||||
|
||||
/// Selectable accent color options with display labels.
|
||||
pub const ACCENT_COLORS: &[(Color, &str)] = &[
|
||||
(Color::RED, "Red"),
|
||||
(Color::GREEN, "Green"),
|
||||
(Color::BLUE, "Blue"),
|
||||
(Color::YELLOW, "Yellow"),
|
||||
(Color::MAGENTA, "Magenta"),
|
||||
(Color::CYAN, "Cyan"),
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static ACCENT_COLOR: Cell<Color> = Cell::new(Color::BLUE);
|
||||
static ACCENT: Cell<Style> = Cell::new(Style::new().fg(Color::BLUE));
|
||||
}
|
||||
|
||||
/// Returns the current accent color.
|
||||
/// The agent's primary accent color, resolved through the active atmosphere.
|
||||
pub fn get_accent_color() -> Color {
|
||||
ACCENT_COLOR.with(|c| c.get())
|
||||
Color::YELLOW
|
||||
}
|
||||
|
||||
/// Returns the current accent style.
|
||||
/// The agent's primary accent as a `Style`.
|
||||
pub fn get_accent() -> Style {
|
||||
ACCENT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Sets the accent color by index into [`ACCENT_COLORS`].
|
||||
pub fn set_accent_color(index: usize) {
|
||||
let (color, _) = ACCENT_COLORS[index % ACCENT_COLORS.len()];
|
||||
ACCENT_COLOR.with(|c| c.set(color));
|
||||
ACCENT.with(|c| c.set(Style::new().fg(color)));
|
||||
}
|
||||
|
||||
/// Returns the index into [`ACCENT_COLORS`] of the current accent color.
|
||||
pub fn get_accent_index() -> usize {
|
||||
let current = get_accent_color();
|
||||
ACCENT_COLORS.iter().position(|(c, _)| *c == current).unwrap_or(2)
|
||||
Style::new().fg(Color::YELLOW)
|
||||
}
|
||||
|
||||
// ── GUI color scheme (light/dark) ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
//! Collapsible panel that animates between a header-only state and a bordered body.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color and souveraine input patterns.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
struct Animation {
|
||||
start: Instant,
|
||||
|
|
@ -75,9 +72,9 @@ impl Accordion {
|
|||
self.expanded = expanded;
|
||||
|
||||
let chevron = if expanded {
|
||||
"\u{25bc}"
|
||||
"v"
|
||||
} else {
|
||||
"\u{25b6}"
|
||||
">"
|
||||
};
|
||||
if let Some(c) = self.root.get_widget_mut(self.chevron_id) {
|
||||
c.set_content(chevron);
|
||||
|
|
@ -132,8 +129,8 @@ impl DelegateWidget for Accordion {
|
|||
let Some(event) = queue.next() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
match &event.chord {
|
||||
chord!(LeftClick) => {
|
||||
let clicked_header = event.mouse_pos.y >= 0
|
||||
&& event.mouse_pos.y < Self::HEADER_HEIGHT as i32;
|
||||
if clicked_header {
|
||||
|
|
@ -152,8 +149,8 @@ impl DelegateWidget for Accordion {
|
|||
}
|
||||
|
||||
impl Accordion {
|
||||
/// Creates a collapsed [`Accordion`] with the given `title`, `content` body, and `accent` color.
|
||||
pub fn new(title: &str, content: Box<dyn Widget>, accent: Color) -> Box<Self> {
|
||||
/// Creates a collapsed [`Accordion`] with the given `title` and `content` body.
|
||||
pub fn new(title: &str, content: Box<dyn Widget>) -> Box<Self> {
|
||||
let mut inner_id = WidgetId::EMPTY;
|
||||
let mut clipper_id = WidgetId::EMPTY;
|
||||
let mut title_id = WidgetId::EMPTY;
|
||||
|
|
@ -182,7 +179,7 @@ impl Accordion {
|
|||
.y_place(Place::Middle)
|
||||
.children([
|
||||
Text::new().content(title.to_string()).flex(1).id(&mut title_id),
|
||||
Text::new().content("\u{25b6}").id(&mut chevron_id),
|
||||
Text::new().content(">").id(&mut chevron_id),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
|
@ -198,16 +195,24 @@ impl Accordion {
|
|||
})
|
||||
}
|
||||
|
||||
/// Creates an [`Accordion`] that starts expanded.
|
||||
pub fn new_expanded(title: &str, content: Box<dyn Widget>, accent: Color) -> Box<Self> {
|
||||
let mut acc = Self::new(title, content, accent);
|
||||
acc.open();
|
||||
acc
|
||||
/// Updates the title text.
|
||||
pub fn set_title(&mut self, title: &str) {
|
||||
if let Some(t) = self.root.get_widget_mut(self.title_id) {
|
||||
t.set_content(title.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the accordion is currently expanded.
|
||||
pub fn is_expanded(&self) -> bool {
|
||||
self.expanded
|
||||
/// Creates an expanded [`Accordion`] (opens immediately, no animation).
|
||||
pub fn new_expanded(title: &str, content: Box<dyn Widget>) -> Box<Self> {
|
||||
let mut acc = Self::new(title, content);
|
||||
acc.expanded = true;
|
||||
if let Some(c) = acc.root.get_widget_mut(acc.chevron_id) {
|
||||
c.set_content("v");
|
||||
}
|
||||
if let Some(clipper) = acc.root.get_widget_mut(acc.clipper_id) {
|
||||
clipper.set_height(None);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Expands the body.
|
||||
|
|
@ -225,11 +230,4 @@ impl Accordion {
|
|||
}
|
||||
self.set_expanded(false);
|
||||
}
|
||||
|
||||
/// Updates the header title text.
|
||||
pub fn set_title(&mut self, title: &str) {
|
||||
if let Some(text) = self.root.get_widget_mut(self.title_id) {
|
||||
text.set_content(title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
//! Bordered focusable button widget.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color.
|
||||
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
use tuie::{delegate_field, prelude::*};
|
||||
use chord_macro::chord;
|
||||
|
||||
use crate::ui::widgets::focus_pane::FocusPane;
|
||||
|
||||
|
|
@ -21,14 +17,14 @@ impl DelegateWidget for Button {
|
|||
let Some(event) = queue.next() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Enter) => {
|
||||
match &event.chord {
|
||||
chord!(Enter) => {
|
||||
tuie::emit(self.get_id(), ClickEvent);
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
tuie::focus_widget(self.get_id());
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let size = self.get_rect_size();
|
||||
let released_inside = Axis2D::all(|axis| {
|
||||
event.mouse_pos[axis] >= 0 && event.mouse_pos[axis] < size[axis] as i32
|
||||
|
|
@ -37,8 +33,8 @@ impl DelegateWidget for Button {
|
|||
tuie::emit(self.get_id(), ClickEvent);
|
||||
}
|
||||
}
|
||||
Trigger::MouseDrag(MouseButton::Left) => {}
|
||||
Trigger::MouseHover => {}
|
||||
chord!(LeftDrag) => {}
|
||||
chord!(Hover) => {}
|
||||
_ => return InputResult::Rejected,
|
||||
}
|
||||
InputResult::Handled
|
||||
|
|
@ -50,10 +46,10 @@ impl DelegateWidget for Button {
|
|||
}
|
||||
|
||||
impl Button {
|
||||
/// Creates an empty button with the given accent color.
|
||||
pub fn new(accent: Color) -> Box<Self> {
|
||||
/// Creates an empty button.
|
||||
pub fn new() -> Box<Self> {
|
||||
Box::new(Self {
|
||||
focus_pane: FocusPane::new(accent),
|
||||
focus_pane: FocusPane::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -67,4 +63,7 @@ impl Button {
|
|||
}
|
||||
self
|
||||
}
|
||||
|
||||
delegate_field!(border_style: Style => focus_pane);
|
||||
delegate_field!(selected_border_style: Option<Style> => focus_pane);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
//! Two-state checkbox widget.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color.
|
||||
|
||||
use tuie::{field, prelude::*};
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
use chord_macro::chord;
|
||||
|
||||
/// Two-state checkbox that emits [`ChangeEvent<bool>`].
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Two-state checkbox that emits [`ChangeEvent`].
|
||||
pub struct Checkbox {
|
||||
root: Box<Pane>,
|
||||
indicator_id: WidgetId<Text>,
|
||||
checked: bool,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl Checkbox {
|
||||
|
|
@ -22,7 +19,11 @@ impl Checkbox {
|
|||
}
|
||||
|
||||
fn sync_indicator(&mut self) {
|
||||
let icon = if self.checked { "[x]" } else { "[ ]" };
|
||||
let icon = if self.checked {
|
||||
"[x]"
|
||||
} else {
|
||||
"[ ]"
|
||||
};
|
||||
if let Some(text) = self.root.get_widget_mut(self.indicator_id) {
|
||||
text.set_content(icon);
|
||||
}
|
||||
|
|
@ -39,9 +40,10 @@ impl DelegateWidget for Checkbox {
|
|||
}
|
||||
|
||||
fn after_on_state_change(&mut self, state: WidgetState) {
|
||||
let accent = theme::get_accent();
|
||||
let style = match state {
|
||||
WidgetState::Focused | WidgetState::FocusedHover => Style::new().fg(self.accent).bold(),
|
||||
WidgetState::Active => Style::new().fg(self.accent),
|
||||
WidgetState::Focused | WidgetState::FocusedHover => accent.bold(),
|
||||
WidgetState::Active => accent,
|
||||
_ => Style::new(),
|
||||
};
|
||||
self.root.set_style(style);
|
||||
|
|
@ -51,19 +53,18 @@ impl DelegateWidget for Checkbox {
|
|||
let Some(event) = queue.next() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Enter) | Trigger::Key(Key::Char(' ')) => {
|
||||
match &event.chord {
|
||||
chord!(Enter | Space) => {
|
||||
self.toggle();
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
tuie::focus_widget(self.get_id());
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let size = self.get_rect_size();
|
||||
let inside = Axis2D::all(|a| {
|
||||
if Axis2D::all(|a| {
|
||||
event.mouse_pos[a] >= 0 && event.mouse_pos[a] < size[a] as i32
|
||||
});
|
||||
if inside {
|
||||
}) {
|
||||
self.toggle();
|
||||
}
|
||||
}
|
||||
|
|
@ -74,8 +75,8 @@ impl DelegateWidget for Checkbox {
|
|||
}
|
||||
|
||||
impl Checkbox {
|
||||
/// Creates an unchecked checkbox with the given label and accent color.
|
||||
pub fn new(label: Box<Text>, accent: Color) -> Box<Self> {
|
||||
/// Creates an unchecked checkbox with the given label.
|
||||
pub fn new(label: Box<Text>) -> Box<Self> {
|
||||
let mut indicator_id = WidgetId::EMPTY;
|
||||
let root = Pane::new()
|
||||
.horizontal()
|
||||
|
|
@ -88,14 +89,7 @@ impl Checkbox {
|
|||
root,
|
||||
indicator_id,
|
||||
checked: false,
|
||||
accent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the initial checked state.
|
||||
pub fn with_checked(mut self: Box<Self>, checked: bool) -> Box<Self> {
|
||||
self.checked = checked;
|
||||
self.sync_indicator();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ impl Cockpit {
|
|||
.id(&mut pressure_label_id);
|
||||
let pressure_bar = {
|
||||
let mut b = ProgressBar::new();
|
||||
b.set_color(dim);
|
||||
b.get_layout_mut().style = Style::new().fg(dim);
|
||||
b
|
||||
};
|
||||
let pbar_holder = pressure_bar.flex(1).id(&mut pressure_bar_id);
|
||||
|
|
@ -177,7 +177,7 @@ impl Cockpit {
|
|||
let color = pressure_color(pressure, palette);
|
||||
if let Some(b) = self.root.get_widget_mut(self.pressure_bar_id) {
|
||||
b.set_progress(pressure.clamp(0.0, 1.0));
|
||||
b.set_color(color);
|
||||
b.get_layout_mut().style = Style::new().fg(color);
|
||||
}
|
||||
if let Some(l) = self.root.get_widget_mut(self.pressure_label_id) {
|
||||
l.set_content(StyledStr::new(&format!(" ctx {pct:>3}% ")).fg(color));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
//! Numeric stepper widget with min/max clamping.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color.
|
||||
//! Numeric stepper widget.
|
||||
|
||||
use chord_macro::chord;
|
||||
use axis2d::Axis2D;
|
||||
use std::any::Any;
|
||||
|
||||
use tuie::{field, prelude::*};
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
fn normalize_numeric(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
|
|
@ -15,7 +11,11 @@ fn normalize_numeric(s: &str) -> String {
|
|||
}
|
||||
let neg = s.starts_with('-');
|
||||
let digits = s.trim_start_matches('-').trim_start_matches('0');
|
||||
let digits = if digits.is_empty() { "0" } else { digits };
|
||||
let digits = if digits.is_empty() {
|
||||
"0"
|
||||
} else {
|
||||
digits
|
||||
};
|
||||
if neg && digits != "0" {
|
||||
format!("-{}", digits)
|
||||
} else {
|
||||
|
|
@ -40,8 +40,12 @@ impl NumericBindings {
|
|||
}
|
||||
|
||||
impl InputBindings<Text> for NumericBindings {
|
||||
fn as_any(&self) -> &dyn Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any { self }
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn configure_state(&self, state: &mut EditorState<Text>) {
|
||||
self.inner.configure_state(state);
|
||||
state.inclusive_selection = false;
|
||||
|
|
@ -55,6 +59,7 @@ impl InputBindings<Text> for NumericBindings {
|
|||
fn on_blur(&mut self, state: &mut EditorState<Text>, text: &Text) {
|
||||
self.inner.on_blur(state, text);
|
||||
}
|
||||
|
||||
fn on_input(
|
||||
&mut self,
|
||||
state: &mut EditorState<Text>,
|
||||
|
|
@ -263,8 +268,8 @@ impl DelegateWidget for Counter {
|
|||
return InputResult::Rejected;
|
||||
};
|
||||
|
||||
match &event.chord.trigger {
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
match &event.chord {
|
||||
chord!(LeftClick) => {
|
||||
tuie::focus_widget(self.input_id);
|
||||
let mouse_pos = event.mouse_pos;
|
||||
if let Some(which) = self.hit_test_buttons(mouse_pos) {
|
||||
|
|
@ -274,7 +279,7 @@ impl DelegateWidget for Counter {
|
|||
}
|
||||
InputResult::Rejected
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let mouse_pos = event.mouse_pos;
|
||||
queue.next();
|
||||
if let Some(which) = self.pressed {
|
||||
|
|
@ -286,28 +291,33 @@ impl DelegateWidget for Counter {
|
|||
self.set_pressed(None);
|
||||
InputResult::Handled
|
||||
}
|
||||
Trigger::Key(Key::Enter) => {
|
||||
chord!(Enter) => {
|
||||
queue.next();
|
||||
self.commit_input();
|
||||
InputResult::Handled
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Up)) => {
|
||||
chord!(Up) => {
|
||||
queue.next();
|
||||
self.adjust(1);
|
||||
self.select_all_input();
|
||||
InputResult::Handled
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Down)) => {
|
||||
chord!(Down) => {
|
||||
queue.next();
|
||||
self.adjust(-1);
|
||||
self.select_all_input();
|
||||
InputResult::Handled
|
||||
}
|
||||
Trigger::Key(Key::Tab) => {
|
||||
chord!(Tab) => {
|
||||
queue.next();
|
||||
tuie::focus_next_tab_order(Sign::Positive);
|
||||
InputResult::Handled
|
||||
}
|
||||
chord!(Shift + Tab) => {
|
||||
queue.next();
|
||||
tuie::focus_next_tab_order(Sign::Negative);
|
||||
InputResult::Handled
|
||||
}
|
||||
_ => InputResult::Rejected,
|
||||
}
|
||||
}
|
||||
|
|
@ -347,8 +357,8 @@ impl DelegateWidget for Counter {
|
|||
}
|
||||
|
||||
impl Counter {
|
||||
/// Creates a [`Counter`] with an optional trailing `label` and accent `color`.
|
||||
pub fn new(label: &str, color: Color) -> Box<Self> {
|
||||
/// Creates a [`Counter`] with an optional trailing `label`.
|
||||
pub fn new(label: &str) -> Box<Self> {
|
||||
let mut minus_id = WidgetId::EMPTY;
|
||||
let mut input_id = WidgetId::EMPTY;
|
||||
let mut plus_id = WidgetId::EMPTY;
|
||||
|
|
@ -383,9 +393,10 @@ impl Counter {
|
|||
value: 0,
|
||||
min: i32::MIN,
|
||||
max: i32::MAX,
|
||||
color,
|
||||
color: Color::BLUE,
|
||||
selected: false,
|
||||
pressed: None,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,16 +84,16 @@ impl DelegateWidget for Dropdown {
|
|||
|
||||
impl Dropdown {
|
||||
/// Creates a dropdown over `labels` with `selected` initially chosen.
|
||||
pub fn new(labels: &[&str], selected: usize, accent: Color) -> Box<Self> {
|
||||
pub fn new(labels: &[&str], selected: usize) -> Box<Self> {
|
||||
let label = labels.get(selected).copied().unwrap_or("");
|
||||
let text = Text::new().content(format!(" {label} \u{25be}"));
|
||||
let mut trigger = FocusPane::new(accent);
|
||||
let mut trigger = FocusPane::new();
|
||||
trigger.add_child(text as Box<dyn Widget>);
|
||||
Box::new(Self {
|
||||
trigger,
|
||||
labels: labels.iter().map(|s| s.to_string()).collect(),
|
||||
selected,
|
||||
accent,
|
||||
accent: Color::YELLOW,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -110,10 +110,8 @@ impl Dropdown {
|
|||
for (i, label) in self.labels.iter().enumerate() {
|
||||
let mut id = WidgetId::EMPTY;
|
||||
let marker = if i == self.selected { "\u{203a}" } else { " " };
|
||||
let row = FlatButton::new(accent)
|
||||
.children([
|
||||
Text::new().content(format!(" {marker} {label} ")) as Box<dyn Widget>,
|
||||
])
|
||||
let row = FlatButton::new()
|
||||
.child(Text::new().content(format!(" {marker} {label} ")))
|
||||
.id(&mut id);
|
||||
row_ids.push(id.untyped());
|
||||
list = list.children([row as Box<dyn Widget>]);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
//! Borderless focusable button with accent hover/focus/active states.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color (the demo pulled the
|
||||
//! accent from a global theme; here it is injected per-instance like the rest
|
||||
//! of souveraine's widgets).
|
||||
//! Borderless focusable button widget.
|
||||
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
use chord_macro::chord;
|
||||
use tuie::render::border;
|
||||
|
||||
/// Borderless focusable button that emits [`ClickEvent`]. Unlike [`Button`],
|
||||
/// it has no border — it tints its background on hover and reverses to the
|
||||
/// accent color on focus/press.
|
||||
///
|
||||
/// [`Button`]: crate::ui::widgets::button::Button
|
||||
pub struct FlatButton {
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Borderless focusable button that emits [`ClickEvent`].
|
||||
pub(crate) struct FlatButton {
|
||||
pane: Box<Pane>,
|
||||
bg: Color,
|
||||
accent: Color,
|
||||
state: WidgetState,
|
||||
base_style: Option<Style>,
|
||||
}
|
||||
|
|
@ -34,12 +26,12 @@ impl FlatButton {
|
|||
}
|
||||
WidgetState::Focused | WidgetState::FocusedHover => {
|
||||
style.bg = None;
|
||||
style.fg = Some(self.accent);
|
||||
style.fg = Some(theme::get_accent_color());
|
||||
style.set_reverse(true);
|
||||
}
|
||||
WidgetState::Active => {
|
||||
style.bg = None;
|
||||
style.fg = Some(self.accent);
|
||||
style.fg = Some(theme::get_accent_color());
|
||||
style.set_reverse(true);
|
||||
style.set_blend(Some(75));
|
||||
}
|
||||
|
|
@ -55,17 +47,18 @@ impl DelegateWidget for FlatButton {
|
|||
let Some(event) = queue.next() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Enter) => {
|
||||
match &event.chord {
|
||||
chord!(Enter) => {
|
||||
tuie::emit(self.get_id(), ClickEvent);
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
tuie::focus_widget(self.get_id());
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let size = self.get_rect_size();
|
||||
let inside = Axis2D::all(|a| {
|
||||
event.mouse_pos[a] >= 0 && event.mouse_pos[a] < size[a] as i32
|
||||
event.mouse_pos[a] >= 0
|
||||
&& event.mouse_pos[a] < size[a] as i32
|
||||
});
|
||||
if inside {
|
||||
tuie::emit(self.get_id(), ClickEvent);
|
||||
|
|
@ -92,32 +85,22 @@ impl DelegateWidget for FlatButton {
|
|||
}
|
||||
|
||||
impl FlatButton {
|
||||
/// Creates an empty flat button with the given `accent` color. The resting
|
||||
/// background defaults to a low-contrast surface tint.
|
||||
pub fn new(accent: Color) -> Box<Self> {
|
||||
/// Creates an empty flat button with no children.
|
||||
pub(crate) fn new() -> Box<Self> {
|
||||
Box::new(Self {
|
||||
pane: Pane::new(),
|
||||
bg: Color::grey256(5),
|
||||
accent,
|
||||
bg: border::config::get().style.bg.unwrap_or(Color::grey256(5)),
|
||||
state: WidgetState::None,
|
||||
base_style: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Overrides the resting background tint.
|
||||
pub fn bg(mut self: Box<Self>, bg: Color) -> Box<Self> {
|
||||
self.bg = bg;
|
||||
self
|
||||
}
|
||||
|
||||
/// Appends the given children to the button in order.
|
||||
pub fn children<const N: usize>(
|
||||
/// Appends `child` to the button.
|
||||
pub(crate) fn child<T: Widget + 'static>(
|
||||
mut self: Box<Self>,
|
||||
children: [Box<dyn Widget>; N],
|
||||
child: Box<T>,
|
||||
) -> Box<Self> {
|
||||
for child in children {
|
||||
self.pane.add_child(child);
|
||||
}
|
||||
self.pane.add_child(child);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,30 @@
|
|||
//! Bordered pane that highlights itself when selected or active.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color.
|
||||
|
||||
use tuie::{delegate_field, field, prelude::*};
|
||||
use tuie::render::border;
|
||||
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Bordered [`Pane`] wrapper that highlights its border when focused.
|
||||
pub struct FocusPane {
|
||||
pub(crate) struct FocusPane {
|
||||
pane: Box<Pane>,
|
||||
border_style: Style,
|
||||
selected_border_style: Option<Style>,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl FocusPane {
|
||||
fn refresh_border(&mut self) {
|
||||
let cfg = border::config::get();
|
||||
let focused = tuie::runtime::is_focus_chain(self.pane.get_id());
|
||||
if focused {
|
||||
let style = self
|
||||
.selected_border_style
|
||||
.unwrap_or_else(|| Style::new().fg(self.accent).bold());
|
||||
.unwrap_or_else(|| cfg.selected_style.apply(theme::get_accent()));
|
||||
self.pane.set_border_style(style);
|
||||
self.pane.set_border(Some(Border::THICK));
|
||||
self.pane.set_border(Some(cfg.selected_border));
|
||||
} else {
|
||||
self.pane.set_border_style(self.border_style);
|
||||
self.pane.set_border(Some(Border::SINGLE));
|
||||
self.pane.set_border(Some(cfg.border));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,24 +47,23 @@ impl DelegateWidget for FocusPane {
|
|||
|
||||
impl FocusPane {
|
||||
/// Creates an empty bordered focus pane.
|
||||
pub fn new(accent: Color) -> Box<Self> {
|
||||
pub(crate) fn new() -> Box<Self> {
|
||||
let mut pane = Pane::new();
|
||||
pane.set_bordered(true);
|
||||
Box::new(Self {
|
||||
pane,
|
||||
border_style: Style::new().fg(Color::grey256(6)).dim(),
|
||||
border_style: Style::new(),
|
||||
selected_border_style: None,
|
||||
accent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends a child widget.
|
||||
pub fn add_child(&mut self, widget: Box<dyn Widget>) {
|
||||
pub(crate) fn add_child(&mut self, widget: Box<dyn Widget>) {
|
||||
self.pane.add_child(widget);
|
||||
}
|
||||
|
||||
/// Appends `children` to the pane.
|
||||
pub fn children<const N: usize>(
|
||||
pub(crate) fn children<const N: usize>(
|
||||
mut self: Box<Self>,
|
||||
children: [Box<dyn Widget>; N],
|
||||
) -> Box<Self> {
|
||||
|
|
@ -75,12 +73,6 @@ impl FocusPane {
|
|||
self
|
||||
}
|
||||
|
||||
/// Sets the accent color used for the focused border.
|
||||
pub fn set_accent(&mut self, accent: Color) {
|
||||
self.accent = accent;
|
||||
self.refresh_border();
|
||||
}
|
||||
|
||||
field!(border_style: Style; sync_border_style);
|
||||
field!(selected_border_style: Option<Style>);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
//! App-wide key chord handler.
|
||||
//!
|
||||
//! Wraps the root widget and intercepts global keyboard shortcuts using
|
||||
//! Trigger-based input matching (not chord! macros). Souveraine already
|
||||
//! handles Ctrl+S (save) and Esc (per-screen back) elsewhere, so those
|
||||
//! are skipped here.
|
||||
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::modifiers::Modifier;
|
||||
use tuie::input::trigger::Trigger;
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
|
||||
/// Root widget wrapper that intercepts app-wide key chords.
|
||||
|
|
@ -22,41 +15,35 @@ impl DelegateWidget for GlobalChords {
|
|||
let Some(event) = queue.peek() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Tab)
|
||||
if !event.chord.modifiers.has(Modifier::Shift) =>
|
||||
{
|
||||
match &event.chord {
|
||||
chord!(Tab) if queue.is_unhandled() => {
|
||||
queue.next();
|
||||
tuie::focus_next_tab_order(Sign::Positive);
|
||||
}
|
||||
Trigger::Key(Key::Tab)
|
||||
if event.chord.modifiers.has(Modifier::Shift) =>
|
||||
{
|
||||
chord!(Shift + Tab) if queue.is_unhandled() => {
|
||||
queue.next();
|
||||
tuie::focus_next_tab_order(Sign::Negative);
|
||||
}
|
||||
Trigger::Key(Key::Char('z'))
|
||||
if event.chord.modifiers.has(Modifier::Ctrl) =>
|
||||
{
|
||||
chord!(Ctrl + z) => {
|
||||
queue.next();
|
||||
let _ = tuie::suspend();
|
||||
}
|
||||
#[cfg(feature = "gui")]
|
||||
Trigger::Key(Key::Char('+'))
|
||||
if event.chord.modifiers.has(Modifier::Ctrl) =>
|
||||
{
|
||||
chord!(Ctrl + Char('+')) => {
|
||||
queue.next();
|
||||
let cur = tuie::gui::config::get().font_size;
|
||||
tuie::gui::set_font_size((cur + 1.0).min(72.0));
|
||||
}
|
||||
#[cfg(feature = "gui")]
|
||||
Trigger::Key(Key::Char('-'))
|
||||
if event.chord.modifiers.has(Modifier::Ctrl) =>
|
||||
{
|
||||
chord!(Ctrl + Char('-')) => {
|
||||
queue.next();
|
||||
let cur = tuie::gui::config::get().font_size;
|
||||
tuie::gui::set_font_size((cur - 1.0).max(6.0));
|
||||
}
|
||||
chord!(Ctrl + (c | q)) => {
|
||||
queue.next();
|
||||
tuie::quit(0);
|
||||
}
|
||||
_ => return InputResult::Rejected,
|
||||
}
|
||||
InputResult::Handled
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use tuie::prelude::*;
|
||||
|
||||
/// One-cell-tall horizontal divider.
|
||||
pub struct HorizontalRule {
|
||||
pub(crate) struct HorizontalRule {
|
||||
layout: Layout,
|
||||
}
|
||||
|
||||
|
|
@ -30,22 +30,15 @@ impl Widget for HorizontalRule {
|
|||
|
||||
fn render(&self, mut ctx: RenderContext) {
|
||||
ctx.set_style(self.layout.style);
|
||||
ctx.fill("\u{2500}");
|
||||
ctx.fill("─");
|
||||
}
|
||||
}
|
||||
|
||||
impl HorizontalRule {
|
||||
/// Creates a [`HorizontalRule`] with a dim foreground.
|
||||
pub fn new() -> Box<Self> {
|
||||
pub(crate) fn new() -> Box<Self> {
|
||||
let mut layout = Layout::new();
|
||||
layout.style = Style::new().fg(Color::grey256(6));
|
||||
Box::new(Self { layout })
|
||||
}
|
||||
|
||||
/// Creates a [`HorizontalRule`] with the given accent color.
|
||||
pub fn with_color(color: Color) -> Box<Self> {
|
||||
let mut layout = Layout::new();
|
||||
layout.style = Style::new().fg(color);
|
||||
Box::new(Self { layout })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
//! Clickable text link widget.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color and souveraine's
|
||||
//! `Trigger`-based input matching. Renders an underlined label that opens its
|
||||
//! URL in the system browser when activated.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Focusable text link that opens a URL when activated.
|
||||
pub struct Link {
|
||||
|
|
@ -18,7 +14,6 @@ pub struct Link {
|
|||
label: String,
|
||||
url: String,
|
||||
state: Cell<WidgetState>,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
|
|
@ -79,9 +74,9 @@ impl Widget for Link {
|
|||
fn render(&self, mut ctx: RenderContext) {
|
||||
let base = self.layout.style;
|
||||
let style = if matches!(self.state.get(), WidgetState::Active) {
|
||||
base.fg(self.accent).underline(UnderlineType::Single)
|
||||
theme::get_accent().apply(base).underline(UnderlineType::Single)
|
||||
} else if self.is_focus_chain() {
|
||||
base.fg(self.accent).bold().underline(UnderlineType::Single)
|
||||
theme::get_accent().bold().apply(base).underline(UnderlineType::Single)
|
||||
} else {
|
||||
base.underline(UnderlineType::Single)
|
||||
};
|
||||
|
|
@ -96,11 +91,11 @@ impl Widget for Link {
|
|||
let Some(event) = queue.next() else {
|
||||
return InputResult::Rejected;
|
||||
};
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Enter) => {
|
||||
match &event.chord {
|
||||
chord!(Enter) => {
|
||||
self.open_url();
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let size = self.get_rect_size();
|
||||
if Axis2D::all(|a| {
|
||||
event.mouse_pos[a] >= 0 && event.mouse_pos[a] < size[a] as i32
|
||||
|
|
@ -109,7 +104,7 @@ impl Widget for Link {
|
|||
self.open_url();
|
||||
}
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
tuie::focus_widget(self.get_id());
|
||||
}
|
||||
_ => return InputResult::Rejected,
|
||||
|
|
@ -120,13 +115,12 @@ impl Widget for Link {
|
|||
|
||||
impl Link {
|
||||
/// Creates a link with the given visible `label` that opens `url` when activated.
|
||||
pub fn new(label: &str, url: &str, accent: Color) -> Box<Self> {
|
||||
pub fn new(label: &str, url: &str) -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
label: label.to_string(),
|
||||
url: url.to_string(),
|
||||
state: Cell::new(WidgetState::None),
|
||||
accent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
//! Stack-based page navigation widget.
|
||||
//!
|
||||
//! Manages a stack of pages with push/pop navigation and focus restoration.
|
||||
//! Ported from tuie-demo.
|
||||
|
||||
use tuie::prelude::*;
|
||||
|
||||
|
|
@ -11,7 +8,7 @@ struct PageEntry {
|
|||
}
|
||||
|
||||
/// Page navigation stack.
|
||||
pub struct PageLayout {
|
||||
pub(crate) struct PageLayout {
|
||||
pane: Box<Pane>,
|
||||
stack: Vec<PageEntry>,
|
||||
top_id: WidgetId,
|
||||
|
|
@ -22,8 +19,7 @@ impl DelegateWidget for PageLayout {
|
|||
}
|
||||
|
||||
impl PageLayout {
|
||||
/// Creates a new [`PageLayout`] with `home` as the root page.
|
||||
pub fn new(home: Box<dyn Widget>) -> Box<Self> {
|
||||
pub(crate) fn new(home: Box<dyn Widget>) -> Box<Self> {
|
||||
let top_id = home.get_id();
|
||||
let pane = Pane::new()
|
||||
.vertical()
|
||||
|
|
@ -42,8 +38,7 @@ impl PageLayout {
|
|||
})
|
||||
}
|
||||
|
||||
/// Pushes `content` onto the page stack, saving the current focus.
|
||||
pub fn push(&mut self, content: Box<dyn Widget>) {
|
||||
pub(crate) fn push(&mut self, content: Box<dyn Widget>) {
|
||||
if let Some(entry) = self.stack.last_mut() {
|
||||
entry.saved_widget = tuie::get_focused_widget();
|
||||
}
|
||||
|
|
@ -62,8 +57,7 @@ impl PageLayout {
|
|||
tuie::dirty_layout();
|
||||
}
|
||||
|
||||
/// Pops the top page, restoring the previous page and its focus.
|
||||
pub fn pop(&mut self) {
|
||||
pub(crate) fn pop(&mut self) {
|
||||
if self.stack.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -82,9 +76,4 @@ impl PageLayout {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of pages on the stack.
|
||||
pub fn depth(&self) -> usize {
|
||||
self.stack.len()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
//! Grid-based point picker widget.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color and souveraine's
|
||||
//! `Trigger`-based input matching. A 2D analog of a radio group — pick one cell
|
||||
//! from an `n×m` grid. Emits `ChangeEvent<Vec2<u16>>` on selection.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Interactive grid for selecting a point cell.
|
||||
pub struct PointPicker {
|
||||
|
|
@ -17,7 +13,6 @@ pub struct PointPicker {
|
|||
size: Cell<Vec2<u16>>,
|
||||
selected: Cell<Vec2<u16>>,
|
||||
pressed: Cell<Option<Vec2<u16>>>,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl PointPicker {
|
||||
|
|
@ -101,21 +96,22 @@ impl Widget for PointPicker {
|
|||
let cell = Vec2::new(col, row);
|
||||
let is_selected = cell == selected;
|
||||
let is_pressed = pressed == Some(cell);
|
||||
let accent = theme::get_accent_color();
|
||||
let style = if is_selected {
|
||||
if focused {
|
||||
Style::new().fg(self.accent).bold()
|
||||
Style::new().fg(accent).bold()
|
||||
} else {
|
||||
Style::new().fg(Color::Foreground).bold()
|
||||
}
|
||||
} else if is_pressed {
|
||||
Style::new().fg(self.accent)
|
||||
Style::new().fg(accent)
|
||||
} else {
|
||||
Style::new().fg(Color::grey256(7))
|
||||
};
|
||||
let marker = if is_selected && !is_pressed {
|
||||
"\u{25cf}"
|
||||
"●"
|
||||
} else {
|
||||
"\u{00b7}"
|
||||
"·"
|
||||
};
|
||||
let x = (col * Self::CELL_W + Self::CELL_W / 2) as i32;
|
||||
let y = (row * Self::CELL_H) as i32;
|
||||
|
|
@ -137,34 +133,34 @@ impl Widget for PointPicker {
|
|||
let size = self.size.get();
|
||||
let selected = self.selected.get();
|
||||
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Arrow(Direction2D::Left)) => {
|
||||
match &event.chord {
|
||||
chord!(Left | h) => {
|
||||
if selected.x > 0 {
|
||||
self.select_cell(Vec2::new(selected.x - 1, selected.y));
|
||||
}
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Right)) => {
|
||||
chord!(Right | l) => {
|
||||
if selected.x + 1 < size.x {
|
||||
self.select_cell(Vec2::new(selected.x + 1, selected.y));
|
||||
}
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Up)) => {
|
||||
chord!(Up | k) => {
|
||||
if selected.y > 0 {
|
||||
self.select_cell(Vec2::new(selected.x, selected.y - 1));
|
||||
}
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Down)) => {
|
||||
chord!(Down | j) => {
|
||||
if selected.y + 1 < size.y {
|
||||
self.select_cell(Vec2::new(selected.x, selected.y + 1));
|
||||
}
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
if let Some(cell) = self.hit_cell(event.mouse_pos) {
|
||||
tuie::focus_widget(self.get_id());
|
||||
self.set_pressed(Some(cell));
|
||||
}
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let pressed = self.pressed.get();
|
||||
self.set_pressed(None);
|
||||
if let Some(cell) = self.hit_cell(event.mouse_pos) {
|
||||
|
|
@ -180,23 +176,16 @@ impl Widget for PointPicker {
|
|||
}
|
||||
|
||||
impl PointPicker {
|
||||
/// Creates a `3×3` [`PointPicker`] with the top-left cell selected.
|
||||
pub fn new(accent: Color) -> Box<Self> {
|
||||
/// Creates a [`PointPicker`] with the top-left cell selected.
|
||||
pub fn new() -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
size: Cell::new(Vec2::new(3, 3)),
|
||||
selected: Cell::new(Vec2::of(0)),
|
||||
pressed: Cell::new(None),
|
||||
accent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the grid dimensions in cells.
|
||||
pub fn grid(self: Box<Self>, size: Vec2<u16>) -> Box<Self> {
|
||||
self.size.set(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the initially selected `point`.
|
||||
pub fn point(self: Box<Self>, point: Vec2<u16>) -> Box<Self> {
|
||||
self.selected.set(point);
|
||||
|
|
|
|||
|
|
@ -1,46 +1,22 @@
|
|||
//! Horizontal eighth-block progress bar widget.
|
||||
//! Horizontal progress bar widget.
|
||||
|
||||
use tuie::{field, prelude::*};
|
||||
|
||||
/// Horizontal progress bar using eighth-block Unicode characters for smooth
|
||||
/// fill rendering. Supports an accent color that can be changed at runtime.
|
||||
pub struct ProgressBar {
|
||||
/// Horizontal progress bar.
|
||||
pub(crate) struct ProgressBar {
|
||||
layout: Layout,
|
||||
progress: f32,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl ProgressBar {
|
||||
/// Creates a new [`ProgressBar`] with zero progress and a dim grey color.
|
||||
pub fn new() -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
progress: 0.0,
|
||||
color: Color::grey256(6),
|
||||
})
|
||||
}
|
||||
|
||||
fn clamp_and_dirty(&mut self) {
|
||||
self.progress = self.progress.clamp(0.0, 1.0);
|
||||
self.dirty_paint();
|
||||
}
|
||||
|
||||
/// Sets the bar's fill color.
|
||||
pub fn set_color(&mut self, color: Color) {
|
||||
self.color = color;
|
||||
self.dirty_paint();
|
||||
}
|
||||
|
||||
field!(progress: f32; clamp_and_dirty);
|
||||
}
|
||||
|
||||
impl Widget for ProgressBar {
|
||||
fn get_layout(&self) -> &Layout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
fn get_layout_mut(&mut self) -> &mut Layout {
|
||||
&mut self.layout
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &'static str {
|
||||
"ProgressBar"
|
||||
}
|
||||
|
|
@ -67,23 +43,37 @@ impl Widget for ProgressBar {
|
|||
row.push('\u{2588}');
|
||||
}
|
||||
if remainder > 0 && full_cells < width {
|
||||
row.push(match remainder {
|
||||
let partial = match remainder {
|
||||
7 => '\u{2589}',
|
||||
6 => '\u{258A}',
|
||||
5 => '\u{258B}',
|
||||
4 => '\u{258C}',
|
||||
3 => '\u{258D}',
|
||||
2 => '\u{258E}',
|
||||
_ => '\u{258F}',
|
||||
});
|
||||
}
|
||||
let drawn = row.chars().count() as u16;
|
||||
for _ in drawn..width {
|
||||
row.push('\u{2591}');
|
||||
1 => '\u{258F}',
|
||||
_ => unreachable!(),
|
||||
};
|
||||
row.push(partial);
|
||||
}
|
||||
|
||||
ctx.set_style(Style::new().fg(self.color));
|
||||
ctx.set_style(self.layout.style);
|
||||
ctx.move_to((0, 0).into());
|
||||
ctx.write(&row);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressBar {
|
||||
pub(crate) fn new() -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
progress: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn clamp_and_dirty(&mut self) {
|
||||
self.progress = self.progress.clamp(0.0, 1.0);
|
||||
self.dirty_paint();
|
||||
}
|
||||
|
||||
field!(progress: f32; clamp_and_dirty);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
//! Vertical radio group control.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color and souveraine's
|
||||
//! `Trigger`-based input matching. Good for small mutually-exclusive enums
|
||||
//! where a horizontal [`SegmentedControl`] gets cramped.
|
||||
//!
|
||||
//! [`SegmentedControl`]: crate::ui::widgets::segmented_control::SegmentedControl
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
/// Vertical group of mutually exclusive labeled radio options. Emits
|
||||
/// `ChangeEvent<usize>` when the selection changes.
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Vertical group of mutually exclusive labeled radio options.
|
||||
pub struct RadioGroup {
|
||||
layout: Layout,
|
||||
labels: Vec<String>,
|
||||
selected: Cell<usize>,
|
||||
pressed: Cell<Option<usize>>,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl RadioGroup {
|
||||
|
|
@ -92,12 +84,13 @@ impl Widget for RadioGroup {
|
|||
let selected = self.selected.get();
|
||||
let pressed = self.pressed.get();
|
||||
let base = self.layout.style;
|
||||
let accent = theme::get_accent_color();
|
||||
let marker_style = if self.is_focus_chain() {
|
||||
base.fg(self.accent).bold()
|
||||
base.fg(accent).bold()
|
||||
} else {
|
||||
base.bold()
|
||||
};
|
||||
let pressed_style = base.fg(self.accent);
|
||||
let pressed_style = base.fg(accent);
|
||||
|
||||
ctx.set_style(base);
|
||||
ctx.clear();
|
||||
|
|
@ -105,7 +98,11 @@ impl Widget for RadioGroup {
|
|||
for (i, label) in self.labels.iter().enumerate() {
|
||||
let is_pressed = pressed == Some(i);
|
||||
let is_selected = i == selected;
|
||||
let marker = if is_selected { "(*)" } else { "( )" };
|
||||
let marker = if is_selected {
|
||||
"(*)"
|
||||
} else {
|
||||
"( )"
|
||||
};
|
||||
let row_style = if is_pressed {
|
||||
pressed_style
|
||||
} else if is_selected {
|
||||
|
|
@ -129,24 +126,24 @@ impl Widget for RadioGroup {
|
|||
let count = self.labels.len();
|
||||
let selected = self.selected.get();
|
||||
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Arrow(Direction2D::Up)) => {
|
||||
match &event.chord {
|
||||
chord!(Up | k) => {
|
||||
if selected > 0 {
|
||||
self.select_index(selected - 1);
|
||||
}
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Down)) => {
|
||||
chord!(Down | j) => {
|
||||
if selected + 1 < count {
|
||||
self.select_index(selected + 1);
|
||||
}
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
if let Some(i) = self.hit_option(event.mouse_pos) {
|
||||
tuie::focus_widget(self.get_id());
|
||||
self.set_pressed(Some(i));
|
||||
}
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let pressed = self.pressed.get();
|
||||
self.set_pressed(None);
|
||||
if let Some(i) = self.hit_option(event.mouse_pos) {
|
||||
|
|
@ -163,13 +160,12 @@ impl Widget for RadioGroup {
|
|||
|
||||
impl RadioGroup {
|
||||
/// Creates a [`RadioGroup`] with one option per label.
|
||||
pub fn new(labels: &[&str], accent: Color) -> Box<Self> {
|
||||
pub fn new(labels: &[&str]) -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
labels: labels.iter().map(|&l| l.to_string()).collect(),
|
||||
selected: Cell::new(0),
|
||||
pressed: Cell::new(None),
|
||||
accent,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -178,9 +174,4 @@ impl RadioGroup {
|
|||
self.selected.set(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the selected option index.
|
||||
pub fn get_selected(&self) -> usize {
|
||||
self.selected.get()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
//! Horizontal toggle control with mutually exclusive labeled segments.
|
||||
//!
|
||||
//! Ported from tuie-demo with palette-aware accent color.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use chord_macro::chord;
|
||||
use tuie::prelude::*;
|
||||
use tuie::input::key::Key;
|
||||
use tuie::input::mouse::MouseButton;
|
||||
use tuie::input::trigger::Trigger;
|
||||
|
||||
use crate::ui::theme;
|
||||
|
||||
/// Horizontal toggle with mutually exclusive labeled segments.
|
||||
pub struct SegmentedControl {
|
||||
|
|
@ -16,7 +14,6 @@ pub struct SegmentedControl {
|
|||
selected: Cell<usize>,
|
||||
pressed: Cell<Option<usize>>,
|
||||
disabled: Cell<u32>,
|
||||
accent: Color,
|
||||
}
|
||||
|
||||
impl SegmentedControl {
|
||||
|
|
@ -106,12 +103,13 @@ impl Widget for SegmentedControl {
|
|||
let selected = self.selected.get();
|
||||
let pressed = self.pressed.get();
|
||||
let base = self.layout.style;
|
||||
let accent = theme::get_accent_color();
|
||||
let selected_style = if self.is_focus_chain() {
|
||||
Style::new().fg(Color::BLACK).bg(self.accent).bold()
|
||||
Style::new().fg(Color::BLACK).bg(accent).bold()
|
||||
} else {
|
||||
base.reverse().bold()
|
||||
};
|
||||
let pressed_style = base.fg(self.accent);
|
||||
let pressed_style = base.fg(accent);
|
||||
|
||||
ctx.set_style(base);
|
||||
ctx.clear();
|
||||
|
|
@ -134,7 +132,7 @@ impl Widget for SegmentedControl {
|
|||
|
||||
if needs_separator {
|
||||
ctx.set_style(base.fg(Color::Rgb(80, 80, 80)));
|
||||
write!(ctx, "\u{258f}");
|
||||
write!(ctx, "▏");
|
||||
ctx.set_style(style);
|
||||
write!(ctx, "{} ", label);
|
||||
} else {
|
||||
|
|
@ -150,18 +148,18 @@ impl Widget for SegmentedControl {
|
|||
};
|
||||
let selected = self.selected.get();
|
||||
|
||||
match &event.chord.trigger {
|
||||
Trigger::Key(Key::Arrow(Direction2D::Left)) => {
|
||||
match &event.chord {
|
||||
chord!(Left | h) => {
|
||||
if let Some(i) = self.step(selected, -1) {
|
||||
self.select_index(i);
|
||||
}
|
||||
}
|
||||
Trigger::Key(Key::Arrow(Direction2D::Right)) => {
|
||||
chord!(Right | l) => {
|
||||
if let Some(i) = self.step(selected, 1) {
|
||||
self.select_index(i);
|
||||
}
|
||||
}
|
||||
Trigger::MouseDown(MouseButton::Left) => {
|
||||
chord!(LeftClick) => {
|
||||
if let Some(i) = self.hit_segment(event.mouse_pos) {
|
||||
if !self.is_disabled(i) {
|
||||
tuie::focus_widget(self.get_id());
|
||||
|
|
@ -169,7 +167,7 @@ impl Widget for SegmentedControl {
|
|||
}
|
||||
}
|
||||
}
|
||||
Trigger::MouseUp(MouseButton::Left) => {
|
||||
chord!(LeftRelease) => {
|
||||
let pressed = self.pressed.get();
|
||||
self.set_pressed(None);
|
||||
if let Some(i) = self.hit_segment(event.mouse_pos) {
|
||||
|
|
@ -186,14 +184,13 @@ impl Widget for SegmentedControl {
|
|||
|
||||
impl SegmentedControl {
|
||||
/// Creates a [`SegmentedControl`] with one segment per label.
|
||||
pub fn new(labels: &[&str], accent: Color) -> Box<Self> {
|
||||
pub fn new(labels: &[&str]) -> Box<Self> {
|
||||
Box::new(Self {
|
||||
layout: Layout::new(),
|
||||
labels: labels.iter().map(|&l| l.to_string()).collect(),
|
||||
selected: Cell::new(0),
|
||||
pressed: Cell::new(None),
|
||||
disabled: Cell::new(0),
|
||||
accent,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -239,10 +236,4 @@ impl SegmentedControl {
|
|||
tuie::dirty_paint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the accent color.
|
||||
pub fn set_accent(&mut self, accent: Color) {
|
||||
self.accent = accent;
|
||||
tuie::dirty_paint();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue