use tairitsu_vdom::{VElement, VNode, VText};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ZoomPosition {
#[default]
TopRight,
TopLeft,
BottomRight,
BottomLeft,
}
#[derive(Clone, PartialEq, Debug)]
pub struct ZoomControlsState {
pub zoom: f64,
pub min_zoom: f64,
pub max_zoom: f64,
pub zoom_step: f64,
pub position: ZoomPosition,
pub show_fit: bool,
pub show_controls: bool,
pub class: String,
}
impl ZoomControlsState {
pub fn new() -> Self {
Self {
zoom: 1.0,
min_zoom: 0.1,
max_zoom: 2.0,
zoom_step: 0.1,
position: ZoomPosition::default(),
show_fit: true,
show_controls: true,
class: String::new(),
}
}
pub fn with_zoom(mut self, zoom: f64) -> Self {
self.zoom = zoom.clamp(self.min_zoom, self.max_zoom);
self
}
pub fn with_bounds(mut self, min: f64, max: f64) -> Self {
self.min_zoom = min;
self.max_zoom = max;
self.zoom = self.zoom.clamp(min, max);
self
}
pub fn with_step(mut self, step: f64) -> Self {
self.zoom_step = step;
self
}
pub fn with_position(mut self, position: ZoomPosition) -> Self {
self.position = position;
self
}
pub fn with_class(mut self, class: impl Into<String>) -> Self {
self.class = class.into();
self
}
pub fn zoom_percent(&self) -> i32 {
(self.zoom * 100.0).round() as i32
}
pub fn can_zoom_in(&self) -> bool {
self.zoom < self.max_zoom
}
pub fn can_zoom_out(&self) -> bool {
self.zoom > self.min_zoom
}
pub fn zoom_in(&mut self) -> bool {
let new_zoom = (self.zoom + self.zoom_step).min(self.max_zoom);
let changed = new_zoom != self.zoom;
self.zoom = new_zoom;
changed
}
pub fn zoom_out(&mut self) -> bool {
let new_zoom = (self.zoom - self.zoom_step).max(self.min_zoom);
let changed = new_zoom != self.zoom;
self.zoom = new_zoom;
changed
}
pub fn reset(&mut self) -> bool {
let changed = self.zoom != 1.0;
self.zoom = 1.0;
changed
}
pub fn set_zoom(&mut self, zoom: f64) -> bool {
let new_zoom = zoom.clamp(self.min_zoom, self.max_zoom);
let changed = new_zoom != self.zoom;
self.zoom = new_zoom;
changed
}
pub fn handle_key(&mut self, key: &str, modifiers_has_control: bool) -> Option<f64> {
match key {
"+" | "=" if !modifiers_has_control => {
self.zoom_in();
Some(self.zoom)
}
"-" | "_" if !modifiers_has_control => {
self.zoom_out();
Some(self.zoom)
}
"0" if !modifiers_has_control => {
self.reset();
Some(self.zoom)
}
_ => None,
}
}
pub fn position_class(&self) -> &'static str {
match self.position {
ZoomPosition::TopRight => "hi-zoom-top-right",
ZoomPosition::TopLeft => "hi-zoom-top-left",
ZoomPosition::BottomRight => "hi-zoom-bottom-right",
ZoomPosition::BottomLeft => "hi-zoom-bottom-left",
}
}
pub fn class_string(&self) -> String {
if self.class.is_empty() {
format!("hi-zoom-controls {}", self.position_class())
} else {
format!("hi-zoom-controls {} {}", self.position_class(), self.class)
}
}
}
impl Default for ZoomControlsState {
fn default() -> Self {
Self::new()
}
}
pub fn render_zoom_controls(state: &ZoomControlsState) -> VNode {
let mut children: Vec<VNode> = Vec::with_capacity(5);
let zoom_out = VNode::Element(
VElement::new("button")
.class("hi-zoom-btn hi-zoom-out")
.attr("aria-label", "Zoom out")
.attr("title", "Zoom out (-)")
.attr(
"disabled",
if state.can_zoom_out() {
"false"
} else {
"true"
},
)
.child(VNode::Text(VText::new("\u{25C0}"))),
);
children.push(zoom_out);
let display = VNode::Element(
VElement::new("div")
.class("hi-zoom-level")
.child(VNode::Text(VText::new(&format!(
"{}%",
state.zoom_percent()
)))),
);
children.push(display);
let zoom_in = VNode::Element(
VElement::new("button")
.class("hi-zoom-btn hi-zoom-in")
.attr("aria-label", "Zoom in")
.attr("title", "Zoom in (+)")
.attr(
"disabled",
if state.can_zoom_in() { "false" } else { "true" },
)
.child(VNode::Text(VText::new("\u{25B6}"))),
);
children.push(zoom_in);
let reset = VNode::Element(
VElement::new("button")
.class("hi-zoom-btn hi-zoom-reset")
.attr("aria-label", "Reset zoom")
.attr("title", "Reset to 100% (0)")
.child(VNode::Text(VText::new("100%"))),
);
children.push(reset);
if state.show_fit {
let fit = VNode::Element(
VElement::new("button")
.class("hi-zoom-btn hi-zoom-fit")
.attr("aria-label", "Fit to screen")
.attr("title", "Fit to screen")
.child(VNode::Text(VText::new("\u{25A1}"))),
);
children.push(fit);
}
VNode::Element(
VElement::new("div")
.class(state.class_string())
.attr("tabindex", "0")
.children(children),
)
}
#[derive(Clone, PartialEq, Debug)]
pub struct ZoomChangeEvent {
pub zoom: f64,
pub previous: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zoom_state_new() {
let state = ZoomControlsState::new();
assert_eq!(state.zoom, 1.0);
assert_eq!(state.min_zoom, 0.1);
assert_eq!(state.max_zoom, 2.0);
assert_eq!(state.zoom_step, 0.1);
}
#[test]
fn test_zoom_in() {
let mut state = ZoomControlsState::new();
assert!(state.zoom_in());
assert!((state.zoom - 1.1).abs() < 0.001);
assert!(state.zoom_in());
assert!((state.zoom - 1.2).abs() < 0.001);
}
#[test]
fn test_zoom_out() {
let mut state = ZoomControlsState::new();
assert!(state.zoom_out());
assert_eq!(state.zoom, 0.9);
assert!(state.zoom_out());
assert_eq!(state.zoom, 0.8);
}
#[test]
fn test_zoom_bounds() {
let mut state = ZoomControlsState::new().with_bounds(0.5, 1.5);
state.set_zoom(2.0);
assert_eq!(state.zoom, 1.5);
state.set_zoom(0.1);
assert_eq!(state.zoom, 0.5);
}
#[test]
fn test_can_zoom() {
let state = ZoomControlsState::new().with_bounds(0.5, 1.5);
let state = state.with_zoom(1.0);
assert!(state.can_zoom_in());
assert!(state.can_zoom_out());
let state = state.with_zoom(1.5);
assert!(!state.can_zoom_in());
assert!(state.can_zoom_out());
let state = state.with_zoom(0.5);
assert!(state.can_zoom_in());
assert!(!state.can_zoom_out());
}
#[test]
fn test_reset() {
let mut state = ZoomControlsState::new();
state.set_zoom(1.5);
assert!(state.reset());
assert_eq!(state.zoom, 1.0);
assert!(!state.reset());
}
#[test]
fn test_zoom_percent() {
let state = ZoomControlsState::new().with_zoom(1.5);
assert_eq!(state.zoom_percent(), 150);
let state = state.with_zoom(0.5);
assert_eq!(state.zoom_percent(), 50);
}
#[test]
fn test_keyboard_shortcuts() {
let mut state = ZoomControlsState::new();
let zoom = state.handle_key("+", false).unwrap();
assert!((zoom - 1.1).abs() < 0.001);
let zoom = state.handle_key("=", false).unwrap();
assert!((zoom - 1.2).abs() < 0.001);
let zoom = state.handle_key("-", false).unwrap();
assert!((zoom - 1.1).abs() < 0.001);
let zoom = state.handle_key("_", false).unwrap();
assert!((zoom - 1.0).abs() < 0.001);
state.set_zoom(1.5);
let zoom = state.handle_key("0", false).unwrap();
assert!((zoom - 1.0).abs() < 0.001);
assert_eq!(state.handle_key("a", false), None);
}
#[test]
fn test_position_class() {
let state = ZoomControlsState::new();
assert_eq!(state.position_class(), "hi-zoom-top-right");
let state = state.with_position(ZoomPosition::BottomLeft);
assert_eq!(state.position_class(), "hi-zoom-bottom-left");
}
#[test]
fn test_builder_pattern() {
let state = ZoomControlsState::new()
.with_zoom(1.5)
.with_bounds(0.5, 3.0)
.with_step(0.2)
.with_position(ZoomPosition::TopLeft)
.with_class("custom-zoom");
assert_eq!(state.zoom, 1.5);
assert_eq!(state.min_zoom, 0.5);
assert_eq!(state.max_zoom, 3.0);
assert_eq!(state.zoom_step, 0.2);
assert_eq!(state.position, ZoomPosition::TopLeft);
assert_eq!(state.class, "custom-zoom");
}
}