use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use yakui::event::{EventInterest, EventResponse, WidgetEvent};
use yakui::font::FontName;
use yakui::geometry::{Color, Constraints, Rect, UVec2, Vec2};
use yakui::input::MouseButton;
use yakui::paint::{PaintRect, Texture, TextureFilter, TextureFormat};
use yakui::style::{TextAlignment, TextStyle};
use yakui::util::{widget, widget_children};
use yakui::widget::{EventContext, LayoutContext, PaintContext, Widget};
use yakui::widgets::{ColoredBox, List, Pad, Reflow, Text};
use yakui::{Alignment, CrossAxisAlignment, Dim2, ManagedTextureId, Pivot, Response};
use super::font::Face;
use super::icons;
use super::state::Panels;
pub mod palette {
use yakui::geometry::Color;
pub const PANEL: Color = Color::rgba(26, 26, 36, 235);
pub const BAR: Color = Color::rgba(41, 41, 54, 245);
pub const OUTLINER: Color = Color::rgba(20, 20, 28, 240);
pub const TEXT: Color = Color::WHITE;
pub const TITLE: Color = Color::rgb(209, 209, 224);
pub const DIM: Color = Color::rgb(140, 140, 153);
pub const BUTTON: [Color; 3] = [
Color::rgb(51, 56, 71),
Color::rgb(77, 84, 107),
Color::rgb(36, 41, 54),
];
pub const ROW: [Color; 3] = [
Color::CLEAR,
Color::rgb(61, 89, 140),
Color::rgb(46, 69, 112),
];
pub const CLOSE: [Color; 3] = [
Color::CLEAR,
Color::rgb(158, 56, 56),
Color::rgb(115, 38, 38),
];
}
pub const SCREEN_MARGIN: f32 = 16.0;
pub const PANEL_PADDING: f32 = 5.0;
pub const TITLE_HEIGHT: f32 = 26.0;
pub fn style(px: f32) -> TextStyle {
let family = yakui::context::dom()
.get_global_or_init(Face::default)
.get();
let mut style = TextStyle::label();
style.font = FontName::new(family.name());
style.font_size = px;
style.color = palette::TEXT;
style
}
pub fn text(px: f32, text: impl Into<String>) -> Response<()> {
text_colored(px, text, palette::TEXT)
}
pub fn text_colored(px: f32, text: impl Into<String>, color: Color) -> Response<()> {
let mut widget = Text::new(px, text.into());
widget.style = style(px);
widget.style.color = color;
widget.show()
}
pub fn screen<F: FnOnce()>(children: F) -> Response<()> {
let mut list = List::column();
list.main_axis_size = yakui::MainAxisSize::Max;
list.cross_axis_alignment = CrossAxisAlignment::Stretch;
list.show(children)
}
pub fn place<F: FnOnce()>(x: f32, y: f32, pivot: Pivot, children: F) -> Response<()> {
Reflow::new(Alignment::new(x, y), pivot, Dim2::ZERO).show(children)
}
pub fn place_px<F: FnOnce()>(at: Vec2, children: F) -> Response<()> {
Reflow::new(
Alignment::TOP_LEFT,
Pivot::TOP_LEFT,
Dim2::pixels(at.x, at.y),
)
.show(children)
}
pub fn corner<F: FnOnce()>(alignment: Alignment, children: F) -> Response<()> {
yakui::align(alignment, || {
yakui::pad(Pad::all(SCREEN_MARGIN), children);
})
}
pub fn panel<F: FnOnce()>(children: F) -> Response<()> {
panel_colored(palette::PANEL, children)
}
pub fn panel_colored<F: FnOnce()>(color: Color, children: F) -> Response<()> {
let response = yakui::opaque(|| {
ColoredBox::container(color).show_children(|| {
yakui::pad(Pad::all(PANEL_PADDING), children);
});
});
yakui::context::dom()
.get_global_or_init(Panels::default)
.0
.borrow_mut()
.push(response.id);
response
}
pub const ADVANCE_PER_EM: f32 = 0.62;
const BUTTON_MIN: Vec2 = Vec2::new(200.0, 56.0);
const BUTTON_PX: f32 = 29.0;
const BUTTON_MARGIN: f32 = 24.0;
pub fn menu<S: AsRef<str>>(labels: &[S]) -> Option<usize> {
let widest = labels
.iter()
.map(|label| label.as_ref().chars().count())
.max()
.unwrap_or(0) as f32;
let width = (widest * BUTTON_PX * ADVANCE_PER_EM + BUTTON_MARGIN * 2.0).max(BUTTON_MIN.x);
let mut clicked = None;
yakui::center(|| {
panel(|| {
let mut column = List::column();
column.item_spacing = 20.0;
column.main_axis_size = yakui::MainAxisSize::Min;
column.show(|| {
for (index, label) in labels.iter().enumerate() {
if sized_button(label.as_ref(), Vec2::new(width, BUTTON_MIN.y), BUTTON_PX) {
clicked = Some(index);
}
}
});
});
});
clicked
}
pub fn button(label: impl Into<String>) -> bool {
sized_button(label, BUTTON_MIN, BUTTON_PX)
}
pub fn small_button(label: impl Into<String>) -> bool {
sized_button(label, Vec2::new(100.0, 28.0), 14.0)
}
pub fn sized_button(label: impl Into<String>, min: Vec2, px: f32) -> bool {
let label = label.into();
let width = label.chars().count() as f32 * px * ADVANCE_PER_EM + BUTTON_MARGIN * 2.0;
let size = Vec2::new(width.max(min.x), min.y);
let mut clicked = false;
yakui::constrained(Constraints::tight(size), || {
let response = clickable(Some(palette::BUTTON), || {
yakui::center(|| {
let mut widget = Text::new(px, label);
widget.style = style(px);
widget.style.align = TextAlignment::Center;
widget.show();
});
});
clicked = response.clicked;
});
clicked
}
pub fn rows<F: FnOnce()>(children: F) -> Response<()> {
panel(|| {
let mut column = List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.cross_axis_alignment = CrossAxisAlignment::Start;
column.show(children);
})
}
pub fn menu_row(
label: impl Into<String>,
leading: Option<&'static str>,
trailing: Option<&'static str>,
) -> Response<ClickResponse> {
const PX: f32 = 19.0;
const ICON: f32 = 12.0;
let label = label.into();
clickable(Some(palette::ROW), || {
yakui::pad(Pad::balanced(10.0, 3.0), || {
let mut row = List::row();
row.item_spacing = 6.0;
row.main_axis_size = yakui::MainAxisSize::Min;
row.cross_axis_alignment = CrossAxisAlignment::Center;
row.show(|| {
if let Some(path) = leading {
icon(path, ICON, palette::TEXT);
}
text(PX, label);
if let Some(path) = trailing {
icon(path, ICON, palette::TEXT);
}
});
});
})
}
pub const MENU_BAR_HEIGHT: f32 = 34.0;
const MENU_BAR_PX: f32 = 15.0;
const MENU_TITLE_PAD: f32 = 10.0;
#[derive(Clone, Debug, Default)]
pub struct MenuBarResponse {
pub clicked: Option<usize>,
pub hovered: Option<usize>,
pub starts: Vec<f32>,
}
pub fn menu_bar(titles: &[&str], open: Option<usize>) -> MenuBarResponse {
let mut response = MenuBarResponse::default();
let mut x = PANEL_PADDING;
panel_colored(palette::BAR, || {
let mut row = List::row();
row.main_axis_size = yakui::MainAxisSize::Min;
row.cross_axis_alignment = CrossAxisAlignment::Center;
row.show(|| {
for (i, title) in titles.iter().enumerate() {
response.starts.push(x);
x += title.len() as f32 * MENU_BAR_PX * ADVANCE_PER_EM + 2.0 * MENU_TITLE_PAD;
let fills = match open == Some(i) {
true => palette::BUTTON,
false => palette::ROW,
};
let title = clickable(Some(fills), || {
yakui::pad(Pad::balanced(MENU_TITLE_PAD, 4.0), || {
text(MENU_BAR_PX, *title);
});
});
if title.clicked {
response.clicked = Some(i);
}
if title.hovering {
response.hovered = Some(i);
}
}
});
});
response
}
pub fn dropdown<F: FnOnce()>(x: f32, children: F) {
place_px(Vec2::new(x, MENU_BAR_HEIGHT), || {
yakui::widgets::Layer::new().show(|| {
rows(children);
});
});
}
#[derive(Clone, Copy, Debug, Default)]
pub struct WindowResponse {
pub closed: bool,
}
pub fn window<F: FnOnce()>(
title: impl Into<String>,
at: Vec2,
width: f32,
closable: bool,
children: F,
) -> WindowResponse {
let title = title.into();
let mut response = WindowResponse::default();
let position = yakui::use_state(move || at);
let position_now = position.get();
place_px(position_now, || {
let body = yakui::opaque(|| {
ColoredBox::container(palette::OUTLINER).show_children(|| {
yakui::constrained(
Constraints {
min: Vec2::new(width, 0.0),
max: Vec2::new(width, f32::INFINITY),
},
|| {
let mut column = List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.cross_axis_alignment = CrossAxisAlignment::Stretch;
column.show(|| {
let drag = yakui::draggable(|| {
ColoredBox::container(palette::BAR).show_children(|| {
yakui::constrained(
Constraints {
min: Vec2::new(0.0, TITLE_HEIGHT),
max: Vec2::new(f32::INFINITY, TITLE_HEIGHT),
},
|| {
let mut bar = List::row();
bar.cross_axis_alignment = CrossAxisAlignment::Center;
bar.show(|| {
yakui::pad(
Pad::horizontal(PANEL_PADDING * 2.0),
|| {
text_colored(19.0, title, palette::TITLE);
},
);
yakui::expanded(|| {});
if closable {
let side = TITLE_HEIGHT - PANEL_PADDING * 2.0;
yakui::pad(Pad::all(PANEL_PADDING), || {
let close =
clickable(Some(palette::CLOSE), || {
icon(
icons::path::X,
side,
palette::TEXT,
);
});
response.closed = close.clicked;
});
}
});
},
);
});
});
if let Some(dragging) = drag.dragging {
position.set(dragging.current);
}
yakui::pad(Pad::all(PANEL_PADDING), children);
});
},
);
});
});
yakui::context::dom()
.get_global_or_init(Panels::default)
.0
.borrow_mut()
.push(body.id);
});
response
}
pub fn progress(fraction: f32, size: Vec2) {
const PADDING: f32 = 3.0;
ColoredBox::sized(Color::rgba(41, 43, 56, 235), size).show_children(|| {
yakui::pad(Pad::all(PADDING), || {
let inner = size - Vec2::splat(PADDING * 2.0);
let filled = Vec2::new(inner.x * fraction.clamp(0.0, 1.0), inner.y);
yakui::align(Alignment::CENTER_LEFT, || {
yakui::colored_box(Color::rgb(107, 158, 219), filled);
});
});
});
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ClickResponse {
pub hovering: bool,
pub clicked: bool,
}
pub fn clickable<F: FnOnce()>(fills: Option<[Color; 3]>, children: F) -> Response<ClickResponse> {
widget_children::<ClickableWidget, F>(children, Clickable { fills })
}
#[derive(Debug)]
pub struct Clickable {
fills: Option<[Color; 3]>,
}
#[derive(Debug)]
pub struct ClickableWidget {
props: Clickable,
hovering: bool,
down: bool,
clicked: bool,
}
impl Widget for ClickableWidget {
type Props<'a> = Clickable;
type Response = ClickResponse;
fn new() -> Self {
Self {
props: Clickable { fills: None },
hovering: false,
down: false,
clicked: false,
}
}
fn update(&mut self, props: Self::Props<'_>) -> Self::Response {
self.props = props;
ClickResponse {
hovering: self.hovering,
clicked: std::mem::take(&mut self.clicked),
}
}
fn paint(&self, ctx: PaintContext<'_>) {
if let Some(fills) = self.props.fills {
let rect = ctx.layout.get(ctx.dom.current()).unwrap().rect;
let mut fill = PaintRect::new(rect);
fill.color = match (self.down, self.hovering) {
(true, _) => fills[2],
(false, true) => fills[1],
(false, false) => fills[0],
};
fill.add(ctx.paint);
}
self.default_paint(ctx);
}
fn event_interest(&self) -> EventInterest {
EventInterest::MOUSE_INSIDE | EventInterest::MOUSE_OUTSIDE
}
fn event(&mut self, _ctx: EventContext<'_>, event: &WidgetEvent) -> EventResponse {
match event {
WidgetEvent::MouseEnter => {
self.hovering = true;
EventResponse::Bubble
}
WidgetEvent::MouseLeave => {
self.hovering = false;
EventResponse::Bubble
}
WidgetEvent::MouseButtonChanged {
button: MouseButton::One,
down,
inside,
..
} => {
if *down {
if *inside {
self.down = true;
return EventResponse::Sink;
}
EventResponse::Bubble
} else {
let was_down = std::mem::take(&mut self.down);
if was_down && *inside {
self.clicked = true;
return EventResponse::Sink;
}
EventResponse::Bubble
}
}
_ => EventResponse::Bubble,
}
}
}
pub fn icon(path: &'static str, side: f32, color: Color) -> Response<()> {
widget::<IconWidget>(Icon { path, side, color })
}
#[derive(Debug)]
pub struct Icon {
path: &'static str,
side: f32,
color: Color,
}
#[derive(Debug)]
pub struct IconWidget {
props: Icon,
}
pub const ICON_SIZE: u32 = 32;
#[derive(Clone, Default)]
struct IconTextures(Rc<RefCell<HashMap<&'static str, Option<ManagedTextureId>>>>);
impl Widget for IconWidget {
type Props<'a> = Icon;
type Response = ();
fn new() -> Self {
Self {
props: Icon {
path: "",
side: 0.0,
color: Color::WHITE,
},
}
}
fn update(&mut self, props: Self::Props<'_>) -> Self::Response {
self.props = props;
}
fn layout(&self, _ctx: LayoutContext<'_>, constraints: Constraints) -> Vec2 {
constraints.constrain(Vec2::splat(self.props.side))
}
fn paint(&self, ctx: PaintContext<'_>) {
let textures = ctx.dom.get_global_or_init(IconTextures::default);
let mut textures = textures.0.borrow_mut();
let texture = *textures.entry(self.props.path).or_insert_with(|| {
let path = self.props.path;
let svg = icons::source(path).or_else(|| {
log::warn!("{path} is not compiled in; add it to `icons::embedded`");
None
})?;
let pixels = icons::rasterize(svg.as_bytes(), ICON_SIZE).or_else(|| {
log::warn!("cannot rasterise {path}");
None
})?;
let mut texture = Texture::new(
TextureFormat::Rgba8Srgb,
UVec2::new(ICON_SIZE, ICON_SIZE),
pixels,
);
texture.min_filter = TextureFilter::Linear;
texture.mag_filter = TextureFilter::Linear;
Some(ctx.paint.add_texture(texture))
});
let Some(texture) = texture else {
return;
};
let rect = ctx.layout.get(ctx.dom.current()).unwrap().rect;
let mut image = PaintRect::new(rect);
image.color = self.props.color;
image.texture = Some((texture.into(), Rect::ONE));
image.add(ctx.paint);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::state::Ui;
use yakui::event::Event;
use yakui::paint::Pipeline;
fn ui() -> Ui {
let mut ui = Ui::new();
ui.yakui.set_surface_size(Vec2::new(400.0, 300.0));
ui.yakui
.set_unscaled_viewport(Rect::from_pos_size(Vec2::ZERO, Vec2::new(400.0, 300.0)));
ui
}
fn painted(ui: &mut Ui) -> (usize, usize) {
let dom = ui.yakui.paint();
let (mut text, mut main) = (0, 0);
for layer in dom.layers().iter() {
for call in &layer.calls {
match call.pipeline {
Pipeline::Text => text += call.indices.len() / 6,
_ => main += call.indices.len() / 6,
}
}
}
(main, text)
}
#[test]
fn a_menu_paints_its_panel_its_buttons_and_their_lettering() {
let mut ui = ui();
ui.yakui.start();
menu(&["NEW GAME", "QUIT"]);
ui.yakui.finish();
let (fills, glyphs) = painted(&mut ui);
assert!(fills >= 3, "a panel and two buttons: {fills}");
assert!(glyphs >= "NEWGAMEQUIT".len(), "a glyph a letter: {glyphs}");
}
#[test]
fn a_button_answers_a_click() {
let mut ui = ui();
let frame = |ui: &mut Ui| {
ui.yakui.start();
let clicked = menu(&["HIT"]) == Some(0);
ui.yakui.finish();
clicked
};
assert!(!frame(&mut ui));
ui.yakui
.handle_event(Event::CursorMoved(Some(Vec2::new(200.0, 150.0))));
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: true,
});
assert!(!frame(&mut ui), "held, not yet let go");
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: false,
});
assert!(frame(&mut ui), "let go on it: a click");
assert!(!frame(&mut ui), "and only the once");
}
#[test]
fn a_window_can_be_dragged_by_its_bar_and_closed_by_its_x() {
let mut ui = ui();
let frame = |ui: &mut Ui| {
ui.yakui.start();
let mut closed = false;
screen(|| {
let response = window("TEST", Vec2::new(20.0, 20.0), 160.0, true, || {
text(14.0, "inside");
});
closed = response.closed;
});
ui.yakui.finish();
closed
};
frame(&mut ui);
let root = ui.yakui.dom().root();
let window_id = {
let dom = ui.yakui.dom();
let screen = dom.get(root).unwrap().children[0];
let reflow = *dom.get(screen).unwrap().children.last().unwrap();
dom.get(reflow).unwrap().children[0]
};
let before = ui.rect_of(window_id).unwrap();
assert_eq!(before.pos(), Vec2::new(20.0, 20.0));
let grab = before.pos() + Vec2::new(30.0, TITLE_HEIGHT * 0.5);
ui.yakui.handle_event(Event::CursorMoved(Some(grab)));
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: true,
});
frame(&mut ui);
ui.yakui
.handle_event(Event::CursorMoved(Some(grab + Vec2::new(100.0, 60.0))));
frame(&mut ui);
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: false,
});
frame(&mut ui);
let after = ui.rect_of(window_id).unwrap();
assert_eq!(after.pos() - before.pos(), Vec2::new(100.0, 60.0));
let x = after.pos() + Vec2::new(after.size().x - TITLE_HEIGHT * 0.5, TITLE_HEIGHT * 0.5);
ui.yakui.handle_event(Event::CursorMoved(Some(x)));
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: true,
});
frame(&mut ui);
ui.yakui.handle_event(Event::MouseButtonChanged {
button: MouseButton::One,
down: false,
});
assert!(frame(&mut ui), "the X closes it");
}
}