#![forbid(unsafe_code)]
pub use stipple_anim as anim;
pub use stipple_core as core;
pub use stipple_geometry as geometry;
pub use stipple_layout as layout;
pub use stipple_platform as platform;
pub use stipple_render as render;
pub use stipple_style as style;
pub use stipple_widgets as widgets;
use std::collections::HashMap;
use stipple_core::{
ActionId, Anchor, BoxStyle, Cx, Damage, DragId, Element, FocusId, Handlers, KeyInput,
LayoutNode, TextPosId, ViewportEvent, ViewportId, caret_index_at, collect_focusables,
collect_viewports, context_at, drag_at, find_action, find_focus, find_text_pos, focus_at,
hit_test, layout, measure, paint, paint_focus, paint_hover, text_pos_at, viewport_at,
};
use stipple_geometry::{PhysicalSize, Point, Rect, ScaleFactor, Size};
use stipple_platform::{
ButtonState, ControlFlow, Event, KeyCode, Modifiers, PointerButton, WindowAttributes, WindowId,
backend,
};
use stipple_render::{Color, Font, Pixmap, Scene, SoftwareRenderer, Surface};
use stipple_style::Theme;
pub struct App<S> {
state: S,
theme: Theme,
font: Option<Font>,
panes: Vec<Pane<S>>,
viewport_content: HashMap<ViewportId, Pixmap>,
viewport_input: Option<ViewportInputFn>,
}
pub type ViewportInputFn = Box<dyn FnMut(ViewportId, ViewportEvent)>;
#[derive(Clone, Copy)]
enum PointerKind {
Down(u8),
Up(u8),
Move,
}
pub type FrameRenderer = Box<dyn FnMut(&Scene, Color, ScaleFactor) -> Pixmap>;
pub type ViewFn<S> = Box<dyn FnMut(&S, &mut Cx<'_, S>) -> Element>;
const FOCUS_RING_MARGIN: f64 = 2.0;
fn inflate(r: Rect, m: f64) -> Rect {
Rect::from_xywh(
r.min_x() - m,
r.min_y() - m,
r.width() + 2.0 * m,
r.height() + 2.0 * m,
)
}
fn merge_regions(base: Damage, extra: Vec<Rect>) -> Damage {
if matches!(base, Damage::Full) || extra.is_empty() {
return base;
}
let mut regions = match base {
Damage::Regions(rs) => rs,
Damage::None => Vec::new(),
Damage::Full => unreachable!("handled above"),
};
regions.extend(extra);
Damage::Regions(regions)
}
struct Pane<S> {
view: ViewFn<S>,
attrs: WindowAttributes,
scale: ScaleFactor,
frame_renderer: Option<FrameRenderer>,
tree: Option<LayoutNode>,
handlers: Handlers<S>,
pressed: Option<ActionId>,
focused: Option<FocusId>,
hovered: Option<ActionId>,
dragging: Option<(DragId, Rect)>,
text_selecting: Option<TextPosId>,
dirty: bool,
presented: Option<LayoutNode>,
last_pixmap: Option<Pixmap>,
painted_hovered: Option<ActionId>,
painted_focused: Option<FocusId>,
memo_cache: std::collections::HashMap<u64, Element>,
scroll_offsets: std::collections::HashMap<stipple_core::ScrollId, f64>,
last_pointer: Point,
overlay_active: bool,
painted_overlay_active: bool,
input_viewport: Option<ViewportId>,
}
impl<S> std::fmt::Debug for App<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("theme", &self.theme)
.field("windows", &self.panes.len())
.finish_non_exhaustive()
}
}
impl<S> Pane<S> {
fn new(view: ViewFn<S>) -> Self {
Self {
view,
attrs: WindowAttributes::new(),
scale: ScaleFactor::IDENTITY,
frame_renderer: None,
tree: None,
handlers: Handlers::default(),
pressed: None,
focused: None,
hovered: None,
dragging: None,
text_selecting: None,
dirty: true,
presented: None,
last_pixmap: None,
painted_hovered: None,
painted_focused: None,
memo_cache: std::collections::HashMap::new(),
scroll_offsets: std::collections::HashMap::new(),
last_pointer: Point::new(0.0, 0.0),
overlay_active: false,
painted_overlay_active: false,
input_viewport: None,
}
}
}
impl<S> App<S> {
pub fn new(state: S, build: impl FnMut(&S, &mut Cx<'_, S>) -> Element + 'static) -> Self {
Self {
state,
theme: Theme::light(),
font: None,
panes: vec![Pane::new(Box::new(build))],
viewport_content: HashMap::new(),
viewport_input: None,
}
}
fn primary(&mut self) -> &mut Pane<S> {
&mut self.panes[0]
}
pub fn open_window(
&mut self,
attrs: WindowAttributes,
view: impl FnMut(&S, &mut Cx<'_, S>) -> Element + 'static,
) {
let mut pane = Pane::new(Box::new(view));
pane.attrs = attrs;
self.panes.push(pane);
}
pub fn font(mut self, font: Font) -> Self {
self.font = Some(font);
self
}
pub fn render_with(
mut self,
renderer: impl FnMut(&Scene, Color, ScaleFactor) -> Pixmap + 'static,
) -> Self {
self.primary().frame_renderer = Some(Box::new(renderer));
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.primary().attrs.title = title.into();
self
}
pub fn logical_size(mut self, size: Size) -> Self {
self.primary().attrs.logical_size = size;
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn scale(mut self, scale: ScaleFactor) -> Self {
self.primary().scale = scale;
self
}
pub fn state(&self) -> &S {
&self.state
}
pub fn with_viewport_content(mut self, id: ViewportId, content: Pixmap) -> Self {
self.viewport_content.insert(id, content);
self
}
pub fn set_viewport_content(&mut self, id: ViewportId, content: Pixmap) {
self.viewport_content.insert(id, content);
for pane in &mut self.panes {
pane.dirty = true;
}
}
pub fn clear_viewport_content(&mut self, id: ViewportId) {
if self.viewport_content.remove(&id).is_some() {
for pane in &mut self.panes {
pane.dirty = true;
}
}
}
pub fn on_viewport_input(
mut self,
sink: impl FnMut(ViewportId, ViewportEvent) + 'static,
) -> Self {
self.viewport_input = Some(Box::new(sink));
self
}
fn try_forward_pointer(&mut self, idx: usize, pos: Point, kind: PointerKind) -> bool {
if self.viewport_input.is_none() {
return false;
}
let Some(tree) = self.panes[idx].tree.as_ref() else {
return false;
};
let Some((id, bounds)) = viewport_at(tree, pos) else {
return false;
};
let local = Point::new(pos.x - bounds.min_x(), pos.y - bounds.min_y());
let event = match kind {
PointerKind::Down(button) => {
self.panes[idx].input_viewport = Some(id);
self.panes[idx].focused = None;
ViewportEvent::PointerDown { local, button }
}
PointerKind::Up(button) => ViewportEvent::PointerUp { local, button },
PointerKind::Move => ViewportEvent::PointerMove { local },
};
if let Some(sink) = self.viewport_input.as_mut() {
sink(id, event);
}
true
}
fn try_forward_wheel(&mut self, idx: usize, pos: Point, dy: f64) -> bool {
if self.viewport_input.is_none() {
return false;
}
let Some(tree) = self.panes[idx].tree.as_ref() else {
return false;
};
let Some((id, bounds)) = viewport_at(tree, pos) else {
return false;
};
let local = Point::new(pos.x - bounds.min_x(), pos.y - bounds.min_y());
if let Some(sink) = self.viewport_input.as_mut() {
sink(id, ViewportEvent::Wheel { local, delta_y: dy });
}
true
}
fn try_forward_key(&mut self, idx: usize, input: KeyInput) -> bool {
let Some(id) = self.panes[idx].input_viewport else {
return false;
};
if let Some(sink) = self.viewport_input.as_mut() {
sink(id, ViewportEvent::Key(input));
true
} else {
false
}
}
}
impl<S> Pane<S> {
fn rasterize(&mut self, scene: Scene, theme: &Theme, scale: ScaleFactor) -> Pixmap {
let bg = theme.palette.background;
match self.frame_renderer.as_mut() {
Some(render) => render(&scene, bg, scale),
None => SoftwareRenderer::new()
.with_background(bg)
.render(scene, scale),
}
}
fn composite_viewports(
&self,
pixmap: &mut Pixmap,
content: &HashMap<ViewportId, Pixmap>,
scale: ScaleFactor,
) {
if content.is_empty() {
return;
}
let Some(tree) = self.tree.as_ref() else {
return;
};
let mut views = Vec::new();
collect_viewports(tree, &mut views);
let s = scale.get();
for (id, bounds) in views {
if let Some(src) = content.get(&id) {
let x = (bounds.min_x() * s).round() as u32;
let y = (bounds.min_y() * s).round() as u32;
pixmap.blit(src, x, y);
}
}
}
fn has_viewports(&self) -> bool {
let Some(tree) = self.tree.as_ref() else {
return false;
};
let mut views = Vec::new();
collect_viewports(tree, &mut views);
!views.is_empty()
}
fn build_frame(&mut self, state: &S, theme: &Theme, font: Option<&Font>) -> Scene {
let theme = *theme; let mut cx = Cx::new(&theme);
cx.set_memo_cache(std::mem::take(&mut self.memo_cache));
let element = (self.view)(state, &mut cx);
self.memo_cache = cx.take_memo_cache();
let overlays = cx.take_overlays();
self.handlers = cx.into_handlers();
let size = self.attrs.logical_size;
let win = Rect::from_xywh(0.0, 0.0, size.width, size.height);
let main = layout(&element, win, font);
let mut tree = if overlays.is_empty() {
main
} else {
let mut roots = Vec::with_capacity(overlays.len() * 2 + 1);
roots.push(main);
for spec in &overlays {
if spec.modal || spec.dismiss.is_some() {
let mut catcher = Element::boxed(BoxStyle {
fill: spec.modal.then(|| Color::rgba(0, 0, 0, 0x80)),
radius: 0.0,
border: None,
})
.width(size.width)
.height(size.height);
catcher.action = spec.dismiss;
roots.push(layout(&catcher, win, font));
}
let m = measure(&spec.content, size, font);
let origin = match spec.anchor {
Anchor::At(p) => p,
Anchor::Center => Point::new(
((size.width - m.width) / 2.0).max(0.0),
((size.height - m.height) / 2.0).max(0.0),
),
};
let bounds = Rect::from_xywh(origin.x, origin.y, m.width, m.height);
roots.push(layout(&spec.content, bounds, font));
}
LayoutNode::container(win, roots)
};
self.overlay_active = !overlays.is_empty();
stipple_core::apply_scroll(&mut tree, &mut self.scroll_offsets);
let mut scene = Scene::new(size);
paint(&tree, &mut scene, font);
if let Some(hid) = self.hovered {
paint_hover(&tree, hid, &mut scene, theme.palette.hover_overlay);
}
if let Some(fid) = self.focused {
paint_focus(
&tree,
fid,
&mut scene,
font,
theme.palette.focus_ring,
theme.palette.text,
theme.palette.selection,
);
}
self.tree = Some(tree);
self.dirty = false;
scene
}
fn take_damage(&mut self) -> Damage {
let overlay_changed = self.overlay_active || self.painted_overlay_active;
let hover_changed = self.hovered != self.painted_hovered;
let focus_changed = self.focused != self.painted_focused;
let damage = match (&self.presented, &self.tree) {
(Some(old), Some(new)) if !overlay_changed && old.bounds == new.bounds => {
let base = stipple_core::diff_trees(old, new);
match self.overlay_damage_rects(old, new, hover_changed, focus_changed) {
Some(rects) => merge_regions(base, rects),
None => Damage::Full, }
}
_ => Damage::Full,
};
self.presented = self.tree.clone();
self.painted_hovered = self.hovered;
self.painted_focused = self.focused;
self.painted_overlay_active = self.overlay_active;
damage
}
fn overlay_damage_rects(
&self,
old: &LayoutNode,
new: &LayoutNode,
hover_changed: bool,
focus_changed: bool,
) -> Option<Vec<Rect>> {
let mut rects = Vec::new();
if hover_changed {
if let Some(id) = self.painted_hovered {
rects.push(find_action(old, id)?.bounds);
}
if let Some(id) = self.hovered {
rects.push(find_action(new, id)?.bounds);
}
}
if focus_changed {
if let Some(id) = self.painted_focused {
rects.push(inflate(find_focus(old, id)?.bounds, FOCUS_RING_MARGIN));
}
if let Some(id) = self.focused {
rects.push(inflate(find_focus(new, id)?.bounds, FOCUS_RING_MARGIN));
}
}
Some(rects)
}
fn render_frame(
&mut self,
state: &S,
theme: &Theme,
font: Option<&Font>,
content: &HashMap<ViewportId, Pixmap>,
) -> (Pixmap, Damage) {
let scene = self.build_frame(state, theme, font);
let scale = self.scale;
let mut pixmap = self.rasterize(scene, theme, scale);
self.composite_viewports(&mut pixmap, content, scale);
let damage = self.take_damage();
(pixmap, damage)
}
fn accessibility_tree(&self) -> Option<stipple_core::AccessNode> {
self.tree
.as_ref()
.map(|t| stipple_core::accessibility_tree(t, self.focused))
}
fn ensure_tree(&mut self, state: &S, theme: &Theme, font: Option<&Font>) {
if self.tree.is_none() || self.dirty {
let _ = self.build_frame(state, theme, font);
}
}
fn click_at(&mut self, state: &mut S, theme: &Theme, font: Option<&Font>, pos: Point) -> bool {
self.ensure_tree(state, theme, font);
let (hit, foc) = self
.tree
.as_ref()
.map(|t| (hit_test(t, pos), focus_at(t, pos)))
.unwrap_or((None, None));
if foc != self.focused {
self.focused = foc;
self.dirty = true;
}
match hit {
Some(id) => {
let ran = self.handlers.dispatch(id, state);
if ran {
self.dirty = true;
}
ran
}
None => false,
}
}
fn context_at(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
pos: Point,
) -> bool {
self.ensure_tree(state, theme, font);
let Some(id) = self.tree.as_ref().and_then(|t| context_at(t, pos)) else {
return false;
};
let ran = self.handlers.dispatch_context(id, pos, state);
if ran {
self.dirty = true;
}
ran
}
fn type_text(&mut self, state: &mut S, theme: &Theme, font: Option<&Font>, text: &str) -> bool {
self.send_key(state, theme, font, KeyInput::Text(text.to_string()))
}
fn press_key(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
input: KeyInput,
) -> bool {
self.send_key(state, theme, font, input)
}
fn send_key(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
input: KeyInput,
) -> bool {
self.ensure_tree(state, theme, font);
let Some(id) = self.focused else { return false };
let ran = self.handlers.dispatch_key(id, &input, state);
if ran {
self.dirty = true;
}
ran
}
fn focus_next(&mut self, state: &S, theme: &Theme, font: Option<&Font>) -> bool {
self.ensure_tree(state, theme, font);
let mut order = Vec::new();
if let Some(t) = self.tree.as_ref() {
collect_focusables(t, &mut order);
}
if order.is_empty() {
return false;
}
let next = match self.focused {
Some(cur) => match order.iter().position(|f| *f == cur) {
Some(i) => order[(i + 1) % order.len()],
None => order[0],
},
None => order[0],
};
let changed = self.focused != Some(next);
self.focused = Some(next);
self.dirty = true;
changed
}
fn drag_at_point(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
pos: Point,
) -> bool {
self.ensure_tree(state, theme, font);
if self.dragging.is_none() {
self.dragging = self.tree.as_ref().and_then(|t| drag_at(t, pos));
}
let Some((id, bounds)) = self.dragging else {
return false;
};
let fraction = if bounds.width() > 0.0 {
((pos.x - bounds.min_x()) / bounds.width()).clamp(0.0, 1.0)
} else {
0.0
};
let ran = self.handlers.dispatch_drag(id, fraction, state);
if ran {
self.dirty = true;
}
ran
}
fn end_drag(&mut self) {
self.dragging = None;
}
fn text_press_at(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
pos: Point,
) -> bool {
self.ensure_tree(state, theme, font);
let Some((id, index, foc)) = self.tree.as_ref().and_then(|t| {
let (id, node) = text_pos_at(t, pos)?;
let index = caret_index_at(node, pos, font)?;
Some((id, index, focus_at(t, pos)))
}) else {
return false;
};
if foc != self.focused {
self.focused = foc;
}
self.text_selecting = Some(id);
self.handlers.dispatch_text_pos(id, index, false, state);
self.dirty = true;
true
}
fn text_drag_at(
&mut self,
state: &mut S,
theme: &Theme,
font: Option<&Font>,
pos: Point,
) -> bool {
let Some(id) = self.text_selecting else {
return false;
};
self.ensure_tree(state, theme, font);
let Some(index) = self
.tree
.as_ref()
.and_then(|t| find_text_pos(t, id))
.and_then(|node| caret_index_at(node, pos, font))
else {
return false;
};
let ran = self.handlers.dispatch_text_pos(id, index, true, state);
if ran {
self.dirty = true;
}
ran
}
fn end_text_select(&mut self) {
self.text_selecting = None;
}
fn hover_at(&mut self, state: &S, theme: &Theme, font: Option<&Font>, pos: Point) -> bool {
self.ensure_tree(state, theme, font);
let now = self.tree.as_ref().and_then(|t| hit_test(t, pos));
let changed = now != self.hovered;
self.hovered = now;
changed
}
fn scroll_by(&mut self, state: &S, theme: &Theme, font: Option<&Font>, dy: f64) -> bool {
self.ensure_tree(state, theme, font);
let Some(id) = self
.tree
.as_ref()
.and_then(|t| stipple_core::scroll_at(t, self.last_pointer))
else {
return false;
};
let off = self.scroll_offsets.entry(id).or_insert(0.0);
let before = *off;
*off = (*off + dy).max(0.0);
let moved = (*off - before).abs() > f64::EPSILON;
if moved {
self.dirty = true;
}
moved
}
}
impl<S> App<S> {
pub fn render_frame(&mut self) -> (Pixmap, Damage) {
let App {
state,
theme,
font,
panes,
viewport_content,
..
} = self;
panes[0].render_frame(state, theme, font.as_ref(), viewport_content)
}
pub fn render_once(&mut self) -> Pixmap {
self.render_frame().0
}
pub fn focused(&self) -> Option<FocusId> {
self.panes[0].focused
}
pub fn accessibility_tree(&self) -> Option<stipple_core::AccessNode> {
self.panes[0].accessibility_tree()
}
pub fn click_at(&mut self, pos: Point) -> bool {
self.pane_click_at(0, pos)
}
pub fn type_text(&mut self, text: &str) -> bool {
self.pane_type_text(0, text)
}
pub fn press_key(&mut self, input: KeyInput) -> bool {
self.pane_press_key(0, input)
}
pub fn focus_next(&mut self) -> bool {
self.pane_focus_next(0)
}
pub fn drag_at_point(&mut self, pos: Point) -> bool {
self.pane_drag_at_point(0, pos)
}
pub fn end_drag(&mut self) {
self.panes[0].end_drag();
}
pub fn text_press_at(&mut self, pos: Point) -> bool {
self.pane_text_press_at(0, pos)
}
pub fn text_drag_at(&mut self, pos: Point) -> bool {
self.pane_text_drag_at(0, pos)
}
pub fn end_text_select(&mut self) {
self.panes[0].end_text_select();
}
pub fn hover_at(&mut self, pos: Point) -> bool {
self.pane_hover_at(0, pos)
}
pub fn scroll_by(&mut self, dy: f64) -> bool {
self.pane_scroll_by(0, dy)
}
fn pane_click_at(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].click_at(state, theme, font.as_ref(), pos)
}
fn pane_context_at(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].context_at(state, theme, font.as_ref(), pos)
}
fn pane_type_text(&mut self, idx: usize, text: &str) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].type_text(state, theme, font.as_ref(), text)
}
fn pane_press_key(&mut self, idx: usize, input: KeyInput) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].press_key(state, theme, font.as_ref(), input)
}
fn pane_focus_next(&mut self, idx: usize) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].focus_next(state, theme, font.as_ref())
}
fn pane_drag_at_point(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].drag_at_point(state, theme, font.as_ref(), pos)
}
fn pane_text_press_at(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].text_press_at(state, theme, font.as_ref(), pos)
}
fn pane_text_drag_at(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].text_drag_at(state, theme, font.as_ref(), pos)
}
fn pane_hover_at(&mut self, idx: usize, pos: Point) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].hover_at(state, theme, font.as_ref(), pos)
}
fn pane_scroll_by(&mut self, idx: usize, dy: f64) -> bool {
let App {
state,
theme,
font,
panes,
..
} = self;
panes[idx].scroll_by(state, theme, font.as_ref(), dy)
}
pub fn run(mut self) {
fn to_platform_a11y(node: &stipple_core::AccessNode) -> stipple_platform::A11yNode {
use stipple_core::Role;
use stipple_platform::A11yRole;
let role = match node.role {
Role::Window => A11yRole::Window,
Role::Group => A11yRole::Group,
Role::Button => A11yRole::Button,
Role::TextField => A11yRole::TextField,
Role::Text => A11yRole::Text,
};
stipple_platform::A11yNode {
role,
name: node.name.clone(),
bounds: (
node.bounds.min_x(),
node.bounds.min_y(),
node.bounds.width(),
node.bounds.height(),
),
focused: node.focused,
children: node.children.iter().map(to_platform_a11y).collect(),
}
}
let primary_attrs = self.panes[0].attrs.clone();
let mut surfaces: Vec<Option<Box<dyn Surface>>> =
(0..self.panes.len()).map(|_| None).collect();
let mut id_to_pane: std::collections::HashMap<WindowId, usize> =
std::collections::HashMap::new();
let mut open_count = 0usize;
let mut opened_extras = false;
let present = |app: &mut Self,
surfaces: &mut [Option<Box<dyn Surface>>],
idx: usize,
window: &dyn stipple_platform::Window,
force: bool| {
let App {
state,
theme,
font,
panes,
viewport_content,
..
} = app;
let pane = &mut panes[idx];
let scale = window.scale_factor();
let scene = pane.build_frame(state, theme, font.as_ref());
if let Some(tree) = pane.accessibility_tree() {
window.set_accessibility_tree(&to_platform_a11y(&tree));
}
let damage = pane.take_damage();
if !force && damage.is_empty() {
return;
}
let phys_size = window.inner_size();
let surface = surfaces[idx].get_or_insert_with(|| window.create_surface());
surface.resize(phys_size);
let bounds = Rect::from_xywh(0.0, 0.0, phys_size.width as f64, phys_size.height as f64);
let can_partial = !force
&& pane.frame_renderer.is_none()
&& !pane.has_viewports()
&& matches!(damage, Damage::Regions(_))
&& pane.last_pixmap.as_ref().map(Pixmap::size) == Some(phys_size);
if can_partial {
let regions = damage.to_physical(scale, bounds);
if regions.is_empty() {
return; }
let s = scale.get();
let renderer = SoftwareRenderer::new().with_background(theme.palette.background);
let pixmap = pane.last_pixmap.as_mut().expect("checked by can_partial");
for r in ®ions {
let (pw, ph) = (r.width() as u32, r.height() as u32);
if pw == 0 || ph == 0 {
continue;
}
let view = Rect::from_xywh(
r.min_x() / s,
r.min_y() / s,
r.width() / s,
r.height() / s,
);
let sub =
renderer.render_region(scene.clone(), view, PhysicalSize::new(pw, ph));
pixmap.blit(&sub, r.min_x() as u32, r.min_y() as u32);
}
surface.present(pixmap, ®ions);
} else {
let mut pixmap = pane.rasterize(scene, theme, scale);
pane.composite_viewports(&mut pixmap, viewport_content, scale);
let regions = if force {
Vec::new()
} else {
damage.to_physical(scale, bounds)
};
surface.present(&pixmap, ®ions);
pane.last_pixmap = Some(pixmap);
}
};
backend::run(primary_attrs, |event, window| {
let wid = window.id();
if !opened_extras {
opened_extras = true;
id_to_pane.insert(wid, 0);
open_count = 1;
for idx in 1..self.panes.len() {
let attrs = self.panes[idx].attrs.clone();
if let Some(id) = window.open_window(attrs) {
id_to_pane.insert(id, idx);
open_count += 1;
}
}
}
let idx = id_to_pane.get(&wid).copied().unwrap_or(0);
match event {
Event::RedrawRequested => {
present(&mut self, &mut surfaces, idx, window, true);
ControlFlow::Wait
}
Event::Resized(size) => {
self.panes[idx].attrs.logical_size = window.scale_factor().to_logical(size);
self.panes[idx].dirty = true;
present(&mut self, &mut surfaces, idx, window, true);
ControlFlow::Wait
}
Event::PointerButton {
button: PointerButton::Right,
state: ButtonState::Pressed,
position,
} => {
if self.try_forward_pointer(idx, position, PointerKind::Down(1))
|| self.pane_context_at(idx, position)
{
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::PointerButton {
button: PointerButton::Left,
state: ButtonState::Pressed,
position,
} => {
if self.try_forward_pointer(idx, position, PointerKind::Down(0)) {
present(&mut self, &mut surfaces, idx, window, false);
return ControlFlow::Wait;
}
self.panes[idx].input_viewport = None;
self.panes[idx].pressed = self.panes[idx]
.tree
.as_ref()
.and_then(|t| hit_test(t, position));
if self.pane_text_press_at(idx, position)
|| self.pane_drag_at_point(idx, position)
{
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::PointerMoved { position } => {
self.panes[idx].last_pointer = position;
if self.panes[idx].text_selecting.is_none()
&& self.panes[idx].dragging.is_none()
&& self.try_forward_pointer(idx, position, PointerKind::Move)
{
present(&mut self, &mut surfaces, idx, window, false);
return ControlFlow::Wait;
}
if self.panes[idx].text_selecting.is_some() {
if self.pane_text_drag_at(idx, position) {
present(&mut self, &mut surfaces, idx, window, false);
}
} else if self.panes[idx].dragging.is_some() {
if self.pane_drag_at_point(idx, position) {
present(&mut self, &mut surfaces, idx, window, false);
}
} else if self.pane_hover_at(idx, position) {
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::PointerButton {
button: PointerButton::Left,
state: ButtonState::Released,
position,
} => {
if self.try_forward_pointer(idx, position, PointerKind::Up(0)) {
present(&mut self, &mut surfaces, idx, window, false);
return ControlFlow::Wait;
}
if self.panes[idx].text_selecting.is_some() {
self.panes[idx].end_text_select();
} else if self.panes[idx].dragging.is_some() {
self.panes[idx].end_drag();
} else {
let down = self.panes[idx].pressed.take();
let up = self.panes[idx]
.tree
.as_ref()
.and_then(|t| hit_test(t, position));
if down.is_some() && down == up && self.pane_click_at(idx, position) {
present(&mut self, &mut surfaces, idx, window, false);
}
}
ControlFlow::Wait
}
Event::Text(text) => {
if self.try_forward_key(idx, KeyInput::Text(text.clone())) {
present(&mut self, &mut surfaces, idx, window, false);
return ControlFlow::Wait;
}
if self.pane_type_text(idx, &text) {
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::Key {
code: KeyCode::Tab,
state: ButtonState::Pressed,
..
} => {
if self.pane_focus_next(idx) {
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::Key {
code,
state: ButtonState::Pressed,
modifiers,
} => {
if let Some(input) = map_key(code, modifiers) {
if self.try_forward_key(idx, input.clone()) {
present(&mut self, &mut surfaces, idx, window, false);
return ControlFlow::Wait;
}
if input == KeyInput::Paste
&& let Some(text) = window.clipboard()
{
stipple_core::set_clipboard_text(&text);
}
let writes_clipboard = matches!(input, KeyInput::Copy | KeyInput::Cut);
if self.pane_press_key(idx, input) {
present(&mut self, &mut surfaces, idx, window, false);
}
if writes_clipboard {
window.set_clipboard(&stipple_core::clipboard_text());
}
}
ControlFlow::Wait
}
Event::Scroll { delta } => {
let at = self.panes[idx].last_pointer;
if self.try_forward_wheel(idx, at, delta.dy)
|| self.pane_scroll_by(idx, delta.dy)
{
present(&mut self, &mut surfaces, idx, window, false);
}
ControlFlow::Wait
}
Event::CloseRequested => {
id_to_pane.remove(&wid);
open_count = open_count.saturating_sub(1);
if open_count == 0 {
ControlFlow::Exit
} else {
window.close_window();
ControlFlow::Wait
}
}
_ => ControlFlow::Wait,
}
});
}
}
fn map_key(code: KeyCode, modifiers: Modifiers) -> Option<KeyInput> {
let shift = modifiers.shift;
Some(match code {
KeyCode::Backspace => KeyInput::Backspace,
KeyCode::Delete => KeyInput::Delete,
KeyCode::ArrowLeft if shift => KeyInput::SelectLeft,
KeyCode::ArrowLeft => KeyInput::Left,
KeyCode::ArrowRight if shift => KeyInput::SelectRight,
KeyCode::ArrowRight => KeyInput::Right,
KeyCode::ArrowUp if shift => KeyInput::SelectUp,
KeyCode::ArrowUp => KeyInput::Up,
KeyCode::ArrowDown if shift => KeyInput::SelectDown,
KeyCode::ArrowDown => KeyInput::Down,
KeyCode::Home if shift => KeyInput::SelectHome,
KeyCode::Home => KeyInput::Home,
KeyCode::End if shift => KeyInput::SelectEnd,
KeyCode::End => KeyInput::End,
KeyCode::Char('a') | KeyCode::Char('A') if modifiers.ctrl || modifiers.meta => {
KeyInput::SelectAll
}
KeyCode::Char('c') | KeyCode::Char('C') if modifiers.ctrl || modifiers.meta => {
KeyInput::Copy
}
KeyCode::Char('x') | KeyCode::Char('X') if modifiers.ctrl || modifiers.meta => {
KeyInput::Cut
}
KeyCode::Char('v') | KeyCode::Char('V') if modifiers.ctrl || modifiers.meta => {
KeyInput::Paste
}
KeyCode::Enter => KeyInput::Enter,
KeyCode::Escape => KeyInput::Escape,
_ => return None,
})
}
pub mod prelude {
pub use crate::App;
pub use stipple_anim::{Easing, Spring, Tween};
pub use stipple_core::ViewportId;
pub use stipple_core::{
Align, Anchor, Axis, BoxStyle, Cx, Element, KeyInput, OverlaySpec, View,
};
pub use stipple_geometry::{Insets, PhysicalSize, Point, Rect, ScaleFactor, Size};
pub use stipple_render::{Color, Font, Pixmap};
pub use stipple_style::Theme;
pub use stipple_style::{Palette, Spacing, Typography};
pub use stipple_widgets::{
EditBuffer, Variant, button, button_labeled, button_variant, checkbox, column, divider,
edit_string, heading, label, menu, menu_item, open_dialog, open_menu, panel, paragraph,
progress_bar, radio, row, scroll, setting_row, slider, spacer, spinner, swatch, switch,
tabs, text_area, text_editor, text_field, tooltip, viewport,
};
}
#[cfg(test)]
mod tests {
use super::*;
use stipple_widgets::{button, column};
struct Counter {
n: i64,
}
fn counter_app() -> App<Counter> {
App::new(
Counter { n: 0 },
|_state: &Counter, cx: &mut Cx<Counter>| {
let theme = *cx.theme();
column(vec![
button(&theme)
.width(200.0)
.height(80.0)
.on_tap(cx, |s: &mut Counter| s.n += 1),
])
},
)
.logical_size(Size::new(200.0, 80.0))
}
#[test]
fn click_dispatches_handler_and_mutates_state() {
let mut app = counter_app();
assert_eq!(app.state().n, 0);
assert!(app.click_at(Point::new(100.0, 40.0)));
assert_eq!(app.state().n, 1);
app.click_at(Point::new(100.0, 40.0));
assert_eq!(app.state().n, 2);
}
#[test]
fn click_outside_any_handler_is_a_noop() {
let mut app = counter_app().logical_size(Size::new(400.0, 400.0));
assert!(!app.click_at(Point::new(300.0, 300.0)));
assert_eq!(app.state().n, 0);
}
#[test]
fn render_once_matches_window_size() {
let mut app = counter_app();
assert_eq!(
app.render_once().size(),
stipple_geometry::PhysicalSize::new(200, 80)
);
}
#[test]
fn input_forwards_to_a_viewport_sink_in_local_coords() {
use std::cell::RefCell;
use std::rc::Rc;
use stipple_core::{ViewportEvent, ViewportId, collect_viewports};
let vid = ViewportId(7);
let log: Rc<RefCell<Vec<(ViewportId, ViewportEvent)>>> = Rc::new(RefCell::new(Vec::new()));
let sink_log = log.clone();
let mut app = App::new((), move |_s: &(), _cx: &mut Cx<()>| {
column(vec![Element::viewport(vid).width(100.0).height(80.0)])
})
.logical_size(Size::new(200.0, 150.0))
.on_viewport_input(move |id, ev| sink_log.borrow_mut().push((id, ev)));
app.render_once();
let mut vps = Vec::new();
collect_viewports(app.panes[0].tree.as_ref().unwrap(), &mut vps);
let (_, bounds) = vps[0];
let inside = Point::new(bounds.min_x() + 10.0, bounds.min_y() + 5.0);
assert!(app.try_forward_pointer(0, inside, PointerKind::Down(0)));
assert_eq!(app.panes[0].input_viewport, Some(vid));
assert!(app.try_forward_key(0, KeyInput::Text("x".into())));
assert!(app.try_forward_pointer(0, inside, PointerKind::Up(0)));
let outside = Point::new(bounds.max_x() + 50.0, bounds.max_y() + 50.0);
assert!(!app.try_forward_pointer(0, outside, PointerKind::Move));
let got = log.borrow();
assert_eq!(got.len(), 3, "down + key + up");
assert!(
matches!(&got[0], (v, ViewportEvent::PointerDown { local, button: 0 })
if *v == vid && *local == Point::new(10.0, 5.0)),
"press forwarded with local coords, got {:?}",
got[0]
);
assert!(matches!(&got[1].1, ViewportEvent::Key(KeyInput::Text(t)) if t == "x"));
assert!(matches!(
got[2].1,
ViewportEvent::PointerUp { button: 0, .. }
));
}
#[test]
fn viewport_content_is_composited_over_the_placeholder() {
use stipple_core::ViewportId;
use stipple_geometry::PhysicalSize;
use stipple_render::Color;
use stipple_widgets::viewport;
let vid = ViewportId(1);
let mut app = App::new((), move |_s: &(), _cx: &mut Cx<()>| {
column(vec![viewport(&Theme::light(), vid, 40.0, 30.0)])
.padding(stipple_geometry::Insets::uniform(10.0))
})
.logical_size(Size::new(100.0, 60.0));
let before = app.render_once();
assert_eq!(before.pixel(20, 20), Some([0x20, 0x24, 0x2c, 0xff]));
let mut content = Pixmap::new(PhysicalSize::new(40, 30));
for px in content.as_bytes_mut().chunks_exact_mut(4) {
px.copy_from_slice(&[255, 0, 255, 255]);
}
app.set_viewport_content(vid, content);
let after = app.render_once();
assert_eq!(
after.pixel(20, 20),
Some([255, 0, 255, 255]),
"content blitted"
);
assert_eq!(
after.pixel(30, 25),
Some([255, 0, 255, 255]),
"interior filled"
);
assert_ne!(
after.pixel(2, 2),
Some([255, 0, 255, 255]),
"padding outside the viewport is not painted with content"
);
let _ = Color::WHITE;
}
fn damage_app() -> App<bool> {
use stipple_core::BoxStyle;
use stipple_render::Color;
App::new(false, |flipped: &bool, cx: &mut Cx<bool>| {
let bottom = if *flipped {
Color::rgb(200, 0, 0)
} else {
Color::rgb(0, 0, 200)
};
column(vec![
Element::boxed(BoxStyle {
fill: Some(Color::rgb(20, 20, 20)),
radius: 0.0,
border: None,
})
.width(200.0)
.height(40.0)
.on_tap(cx, |f: &mut bool| *f = !*f),
Element::boxed(BoxStyle {
fill: Some(bottom),
radius: 0.0,
border: None,
})
.width(200.0)
.height(40.0),
])
})
.logical_size(Size::new(200.0, 80.0))
}
#[test]
fn first_frame_is_full_then_unchanged_is_none() {
let mut app = damage_app();
let (_p, d0) = app.render_frame();
assert_eq!(d0, Damage::Full, "first frame must repaint everything");
let (_p, d1) = app.render_frame();
assert_eq!(d1, Damage::None);
assert!(d1.is_empty());
}
#[test]
fn state_change_damages_only_the_changed_box() {
let mut app = damage_app();
let _ = app.render_frame();
assert!(app.click_at(Point::new(100.0, 20.0)));
let (_p, d) = app.render_frame();
let bound = match d {
Damage::Regions(_) => d.bounding().expect("some region"),
other => panic!("expected localized regions, got {other:?}"),
};
assert!(bound.min_y() >= 40.0, "damage strayed above the bottom box");
assert!(bound.height() <= 40.0, "damage taller than the bottom box");
assert!(bound.width() <= 200.0);
}
#[test]
fn hover_change_damages_only_the_hovered_box() {
let mut app = damage_app();
let _ = app.render_frame();
assert!(app.hover_at(Point::new(100.0, 20.0)));
let (_p, d) = app.render_frame();
let bound = match &d {
Damage::Regions(_) => d.bounding().expect("some region"),
other => panic!("expected localized regions, got {other:?}"),
};
assert!(
bound.max_y() <= 40.0,
"hover damage strayed into the bottom box"
);
assert!(bound.height() <= 40.0, "hover damage taller than the box");
assert!(app.hover_at(Point::new(100.0, 60.0)));
let (_p, d) = app.render_frame();
let bound = match &d {
Damage::Regions(_) => d.bounding().expect("some region"),
other => panic!("expected localized regions on unhover, got {other:?}"),
};
assert!(
bound.max_y() <= 40.0,
"unhover damage strayed past the old box"
);
}
#[derive(Default)]
struct Form {
name: String,
}
fn form_app() -> App<Form> {
App::new(Form::default(), |state: &Form, cx: &mut Cx<Form>| {
let theme = *cx.theme();
stipple_widgets::text_field(cx, &theme, &state.name, |s: &mut Form, k| {
stipple_widgets::edit_string(&mut s.name, k)
})
.width(300.0)
.height(100.0)
})
.logical_size(Size::new(300.0, 100.0))
}
#[test]
fn focus_and_type_edits_state() {
let mut app = form_app();
assert!(!app.type_text("x"));
assert_eq!(app.state().name, "");
app.click_at(Point::new(150.0, 50.0));
assert!(app.focused().is_some());
assert!(app.type_text("Ada"));
assert_eq!(app.state().name, "Ada");
assert!(app.press_key(KeyInput::Backspace));
assert_eq!(app.state().name, "Ad");
}
#[derive(Default)]
struct Toggles {
on: bool,
}
#[test]
fn checkbox_toggles_on_click() {
let mut app = App::new(Toggles::default(), |s: &Toggles, cx: &mut Cx<Toggles>| {
let theme = *cx.theme();
stipple_widgets::checkbox(cx, &theme, s.on, |t: &mut Toggles| t.on = !t.on)
.width(100.0)
.height(100.0)
})
.logical_size(Size::new(100.0, 100.0));
assert!(!app.state().on);
app.click_at(Point::new(50.0, 50.0));
assert!(app.state().on);
app.click_at(Point::new(50.0, 50.0));
assert!(!app.state().on);
}
#[derive(Default)]
struct Volume {
level: f64,
}
#[test]
fn slider_drag_sets_value_from_position() {
let mut app = App::new(Volume::default(), |s: &Volume, cx: &mut Cx<Volume>| {
let theme = *cx.theme();
stipple_widgets::slider(cx, &theme, s.level, |v: &mut Volume, f| v.level = f)
.width(200.0)
})
.logical_size(Size::new(200.0, 40.0));
app.drag_at_point(Point::new(150.0, 20.0));
assert!(
(app.state().level - 0.75).abs() < 1e-9,
"got {}",
app.state().level
);
app.drag_at_point(Point::new(50.0, 20.0));
assert!((app.state().level - 0.25).abs() < 1e-9);
app.end_drag();
}
#[test]
fn tab_focuses_first_field() {
let mut app = form_app();
assert!(app.focused().is_none());
assert!(app.focus_next());
assert!(app.focused().is_some());
assert!(app.type_text("hi"));
assert_eq!(app.state().name, "hi");
}
#[test]
fn accessibility_tree_reflects_focus() {
use stipple_core::Role;
let mut app = App::new(Form::default(), |state: &Form, cx: &mut Cx<Form>| {
let theme = *cx.theme();
let field = stipple_widgets::text_field(cx, &theme, &state.name, |s: &mut Form, k| {
stipple_widgets::edit_string(&mut s.name, k)
});
column(vec![field])
})
.logical_size(Size::new(300.0, 100.0));
app.focus_next(); let tree = app.accessibility_tree().expect("a frame was built");
assert_eq!(tree.role, Role::Window);
let focused: Vec<_> = tree
.descendants()
.into_iter()
.filter(|n| n.focused)
.collect();
assert_eq!(focused.len(), 1);
assert_eq!(focused[0].role, Role::TextField);
}
}