Complete Clean Architecture migration
Phase 1-7: Full migration from src/app/ to Clean Architecture
BREAKING CHANGES:
- Removed src/app/ (old TEA-style implementation)
- Removed src/constant.rs (constants now local to modules)
- Removed deprecated canvas_to_image_coords functions
NEW STRUCTURE:
- src/ui/ - UI Layer (COSMIC interface)
- src/application/ - Application Layer (DocumentManager, Commands)
- src/domain/ - Domain Layer (Document types, Operations)
- src/infrastructure/ - Infrastructure Layer (Loaders, Cache, System)
FEATURES:
- DocumentManager as Single Source of Truth
- Command Pattern for all operations
- Model caching for render data (performance)
- Sync mechanism between DocumentManager and UI Model
- Wallpaper support (COSMIC, KDE, GNOME, feh)
- Thumbnail cache with disk persistence
IMPROVEMENTS:
- Warnings: 62 → 43 (-31%)
- Deprecated warnings: 2 → 0 (-100%)
- Code removed: src/app/ (~2000 lines), constant.rs, deprecated functions
- Better Locality of Reference (constants local to modules)
- Clean separation of concerns
- No circular dependencies
DOCUMENTATION:
- Updated AGENTS.md (100% migration status)
- Updated README.md (architecture section)
- Updated Workflow.md
- Added Migration-Plan.md with full completion summary
TESTS:
- All 41 tests passing
- Build successful (0 errors, 43 warnings)
- Release build verified
Migration Status: ✅ 100% Complete
This commit is contained in:
parent
f8087a3c6a
commit
fc73e4b76b
87 changed files with 9461 additions and 3324 deletions
321
src/domain/viewport/bounds.rs
Normal file
321
src/domain/viewport/bounds.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/viewport/bounds.rs
|
||||
//
|
||||
// Bounding box calculations and intersection tests for viewport.
|
||||
|
||||
/// A rectangular bounding box.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounds {
|
||||
/// X coordinate of top-left corner.
|
||||
pub x: f32,
|
||||
/// Y coordinate of top-left corner.
|
||||
pub y: f32,
|
||||
/// Width of the bounds.
|
||||
pub width: f32,
|
||||
/// Height of the bounds.
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
/// Create a new bounds rectangle.
|
||||
#[must_use]
|
||||
pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create bounds from two points (top-left and bottom-right).
|
||||
#[must_use]
|
||||
pub fn from_corners(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
|
||||
let x = x1.min(x2);
|
||||
let y = y1.min(y2);
|
||||
let width = (x2 - x1).abs();
|
||||
let height = (y2 - y1).abs();
|
||||
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create bounds centered at a point.
|
||||
#[must_use]
|
||||
pub fn centered(center_x: f32, center_y: f32, width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
x: center_x - width / 2.0,
|
||||
y: center_y - height / 2.0,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the right edge coordinate.
|
||||
#[must_use]
|
||||
pub fn right(&self) -> f32 {
|
||||
self.x + self.width
|
||||
}
|
||||
|
||||
/// Get the bottom edge coordinate.
|
||||
#[must_use]
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.y + self.height
|
||||
}
|
||||
|
||||
/// Get the center point.
|
||||
#[must_use]
|
||||
pub fn center(&self) -> (f32, f32) {
|
||||
(self.x + self.width / 2.0, self.y + self.height / 2.0)
|
||||
}
|
||||
|
||||
/// Get the top-left corner.
|
||||
#[must_use]
|
||||
pub fn top_left(&self) -> (f32, f32) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
|
||||
/// Get the top-right corner.
|
||||
#[must_use]
|
||||
pub fn top_right(&self) -> (f32, f32) {
|
||||
(self.right(), self.y)
|
||||
}
|
||||
|
||||
/// Get the bottom-left corner.
|
||||
#[must_use]
|
||||
pub fn bottom_left(&self) -> (f32, f32) {
|
||||
(self.x, self.bottom())
|
||||
}
|
||||
|
||||
/// Get the bottom-right corner.
|
||||
#[must_use]
|
||||
pub fn bottom_right(&self) -> (f32, f32) {
|
||||
(self.right(), self.bottom())
|
||||
}
|
||||
|
||||
/// Check if a point is inside this bounds.
|
||||
#[must_use]
|
||||
pub fn contains_point(&self, x: f32, y: f32) -> bool {
|
||||
x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
|
||||
}
|
||||
|
||||
/// Check if this bounds fully contains another bounds.
|
||||
#[must_use]
|
||||
pub fn contains_bounds(&self, other: &Self) -> bool {
|
||||
other.x >= self.x
|
||||
&& other.y >= self.y
|
||||
&& other.right() <= self.right()
|
||||
&& other.bottom() <= self.bottom()
|
||||
}
|
||||
|
||||
/// Check if this bounds intersects with another bounds.
|
||||
#[must_use]
|
||||
pub fn intersects(&self, other: &Self) -> bool {
|
||||
self.x < other.right()
|
||||
&& self.right() > other.x
|
||||
&& self.y < other.bottom()
|
||||
&& self.bottom() > other.y
|
||||
}
|
||||
|
||||
/// Calculate the intersection of two bounds.
|
||||
///
|
||||
/// Returns None if the bounds don't intersect.
|
||||
#[must_use]
|
||||
pub fn intersection(&self, other: &Self) -> Option<Self> {
|
||||
if !self.intersects(other) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x = self.x.max(other.x);
|
||||
let y = self.y.max(other.y);
|
||||
let right = self.right().min(other.right());
|
||||
let bottom = self.bottom().min(other.bottom());
|
||||
|
||||
Some(Self::new(x, y, right - x, bottom - y))
|
||||
}
|
||||
|
||||
/// Calculate the union of two bounds (bounding box containing both).
|
||||
#[must_use]
|
||||
pub fn union(&self, other: &Self) -> Self {
|
||||
let x = self.x.min(other.x);
|
||||
let y = self.y.min(other.y);
|
||||
let right = self.right().max(other.right());
|
||||
let bottom = self.bottom().max(other.bottom());
|
||||
|
||||
Self::new(x, y, right - x, bottom - y)
|
||||
}
|
||||
|
||||
/// Expand the bounds by a margin on all sides.
|
||||
#[must_use]
|
||||
pub fn expand(&self, margin: f32) -> Self {
|
||||
Self::new(
|
||||
self.x - margin,
|
||||
self.y - margin,
|
||||
self.width + 2.0 * margin,
|
||||
self.height + 2.0 * margin,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shrink the bounds by a margin on all sides.
|
||||
///
|
||||
/// Returns None if the bounds would become invalid.
|
||||
#[must_use]
|
||||
pub fn shrink(&self, margin: f32) -> Option<Self> {
|
||||
let new_width = self.width - 2.0 * margin;
|
||||
let new_height = self.height - 2.0 * margin;
|
||||
|
||||
if new_width <= 0.0 || new_height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::new(
|
||||
self.x + margin,
|
||||
self.y + margin,
|
||||
new_width,
|
||||
new_height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Scale the bounds by a factor from center.
|
||||
#[must_use]
|
||||
pub fn scale(&self, factor: f32) -> Self {
|
||||
let (center_x, center_y) = self.center();
|
||||
let new_width = self.width * factor;
|
||||
let new_height = self.height * factor;
|
||||
|
||||
Self::centered(center_x, center_y, new_width, new_height)
|
||||
}
|
||||
|
||||
/// Translate the bounds by an offset.
|
||||
#[must_use]
|
||||
pub fn translate(&self, dx: f32, dy: f32) -> Self {
|
||||
Self::new(self.x + dx, self.y + dy, self.width, self.height)
|
||||
}
|
||||
|
||||
/// Get the area of the bounds.
|
||||
#[must_use]
|
||||
pub fn area(&self) -> f32 {
|
||||
self.width * self.height
|
||||
}
|
||||
|
||||
/// Check if the bounds is empty (zero or negative area).
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.width <= 0.0 || self.height <= 0.0
|
||||
}
|
||||
|
||||
/// Clamp this bounds to fit within another bounds.
|
||||
#[must_use]
|
||||
pub fn clamp_to(&self, container: &Self) -> Self {
|
||||
let x = self.x.max(container.x).min(container.right() - self.width);
|
||||
let y = self.y.max(container.y).min(container.bottom() - self.height);
|
||||
|
||||
Self::new(x, y, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bounds {
|
||||
fn default() -> Self {
|
||||
Self::new(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bounds_creation() {
|
||||
let bounds = Bounds::new(10.0, 20.0, 100.0, 200.0);
|
||||
assert_eq!(bounds.x, 10.0);
|
||||
assert_eq!(bounds.y, 20.0);
|
||||
assert_eq!(bounds.width, 100.0);
|
||||
assert_eq!(bounds.height, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounds_from_corners() {
|
||||
let bounds = Bounds::from_corners(10.0, 20.0, 110.0, 220.0);
|
||||
assert_eq!(bounds.x, 10.0);
|
||||
assert_eq!(bounds.y, 20.0);
|
||||
assert_eq!(bounds.width, 100.0);
|
||||
assert_eq!(bounds.height, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounds_edges() {
|
||||
let bounds = Bounds::new(10.0, 20.0, 100.0, 200.0);
|
||||
assert_eq!(bounds.right(), 110.0);
|
||||
assert_eq!(bounds.bottom(), 220.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_point() {
|
||||
let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert!(bounds.contains_point(50.0, 50.0));
|
||||
assert!(bounds.contains_point(0.0, 0.0));
|
||||
assert!(bounds.contains_point(100.0, 100.0));
|
||||
assert!(!bounds.contains_point(-1.0, 50.0));
|
||||
assert!(!bounds.contains_point(50.0, 101.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersection() {
|
||||
let a = Bounds::new(0.0, 0.0, 100.0, 100.0);
|
||||
let b = Bounds::new(50.0, 50.0, 100.0, 100.0);
|
||||
|
||||
let intersection = a.intersection(&b).unwrap();
|
||||
assert_eq!(intersection.x, 50.0);
|
||||
assert_eq!(intersection.y, 50.0);
|
||||
assert_eq!(intersection.width, 50.0);
|
||||
assert_eq!(intersection.height, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_intersection() {
|
||||
let a = Bounds::new(0.0, 0.0, 100.0, 100.0);
|
||||
let b = Bounds::new(200.0, 200.0, 100.0, 100.0);
|
||||
|
||||
assert!(!a.intersects(&b));
|
||||
assert!(a.intersection(&b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_union() {
|
||||
let a = Bounds::new(0.0, 0.0, 100.0, 100.0);
|
||||
let b = Bounds::new(50.0, 50.0, 100.0, 100.0);
|
||||
|
||||
let union = a.union(&b);
|
||||
assert_eq!(union.x, 0.0);
|
||||
assert_eq!(union.y, 0.0);
|
||||
assert_eq!(union.width, 150.0);
|
||||
assert_eq!(union.height, 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_shrink() {
|
||||
let bounds = Bounds::new(10.0, 10.0, 100.0, 100.0);
|
||||
|
||||
let expanded = bounds.expand(10.0);
|
||||
assert_eq!(expanded.x, 0.0);
|
||||
assert_eq!(expanded.width, 120.0);
|
||||
|
||||
let shrunk = bounds.shrink(10.0).unwrap();
|
||||
assert_eq!(shrunk.x, 20.0);
|
||||
assert_eq!(shrunk.width, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale() {
|
||||
let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
|
||||
let scaled = bounds.scale(2.0);
|
||||
|
||||
assert_eq!(scaled.width, 200.0);
|
||||
assert_eq!(scaled.height, 200.0);
|
||||
assert_eq!(scaled.center(), bounds.center());
|
||||
}
|
||||
}
|
||||
236
src/domain/viewport/camera.rs
Normal file
236
src/domain/viewport/camera.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/viewport/camera.rs
|
||||
//
|
||||
// Camera controls and transformations for viewport navigation.
|
||||
|
||||
use super::viewport::Viewport;
|
||||
|
||||
/// Camera pan direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanDirection {
|
||||
/// Pan left.
|
||||
Left,
|
||||
/// Pan right.
|
||||
Right,
|
||||
/// Pan up.
|
||||
Up,
|
||||
/// Pan down.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Camera movement speed presets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanSpeed {
|
||||
/// Slow pan (10% of viewport).
|
||||
Slow,
|
||||
/// Normal pan (25% of viewport).
|
||||
Normal,
|
||||
/// Fast pan (50% of viewport).
|
||||
Fast,
|
||||
}
|
||||
|
||||
impl PanSpeed {
|
||||
/// Get the multiplier for this speed.
|
||||
#[must_use]
|
||||
pub fn multiplier(self) -> f32 {
|
||||
match self {
|
||||
Self::Slow => 0.1,
|
||||
Self::Normal => 0.25,
|
||||
Self::Fast => 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PanSpeed {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Camera controller for viewport navigation.
|
||||
///
|
||||
/// Provides high-level camera operations like directional panning,
|
||||
/// smooth zooming, and bounds checking.
|
||||
pub struct Camera {
|
||||
/// Default pan speed.
|
||||
pan_speed: PanSpeed,
|
||||
/// Zoom step multiplier.
|
||||
zoom_step: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Create a new camera controller with default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pan_speed: PanSpeed::default(),
|
||||
zoom_step: 1.25,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default pan speed.
|
||||
pub fn set_pan_speed(&mut self, speed: PanSpeed) {
|
||||
self.pan_speed = speed;
|
||||
}
|
||||
|
||||
/// Set the zoom step multiplier.
|
||||
pub fn set_zoom_step(&mut self, step: f32) {
|
||||
self.zoom_step = step.max(1.01);
|
||||
}
|
||||
|
||||
/// Pan the viewport in a specific direction.
|
||||
///
|
||||
/// The pan amount is calculated as a percentage of the canvas size
|
||||
/// based on the current pan speed.
|
||||
pub fn pan(&self, viewport: &mut Viewport, direction: PanDirection) {
|
||||
self.pan_with_speed(viewport, direction, self.pan_speed);
|
||||
}
|
||||
|
||||
/// Pan with a specific speed.
|
||||
pub fn pan_with_speed(
|
||||
&self,
|
||||
viewport: &mut Viewport,
|
||||
direction: PanDirection,
|
||||
speed: PanSpeed,
|
||||
) {
|
||||
let (canvas_width, canvas_height) = viewport.canvas_size();
|
||||
let multiplier = speed.multiplier();
|
||||
|
||||
let (dx, dy) = match direction {
|
||||
PanDirection::Left => (canvas_width * multiplier, 0.0),
|
||||
PanDirection::Right => (-canvas_width * multiplier, 0.0),
|
||||
PanDirection::Up => (0.0, canvas_height * multiplier),
|
||||
PanDirection::Down => (0.0, -canvas_height * multiplier),
|
||||
};
|
||||
|
||||
viewport.pan_by(dx, dy);
|
||||
}
|
||||
|
||||
/// Zoom in using the default zoom step.
|
||||
pub fn zoom_in(&self, viewport: &mut Viewport) {
|
||||
viewport.zoom_in(self.zoom_step);
|
||||
}
|
||||
|
||||
/// Zoom out using the default zoom step.
|
||||
pub fn zoom_out(&self, viewport: &mut Viewport) {
|
||||
viewport.zoom_out(self.zoom_step);
|
||||
}
|
||||
|
||||
/// Zoom to a specific scale factor.
|
||||
pub fn zoom_to(&self, viewport: &mut Viewport, scale: f32) {
|
||||
viewport.set_scale(scale);
|
||||
}
|
||||
|
||||
/// Center the document in the viewport.
|
||||
pub fn center(&self, viewport: &mut Viewport) {
|
||||
viewport.reset_pan();
|
||||
}
|
||||
|
||||
/// Calculate pan delta to center a specific point in the viewport.
|
||||
///
|
||||
/// Returns (dx, dy) to apply to pan offset.
|
||||
#[must_use]
|
||||
pub fn calculate_pan_to_center_point(
|
||||
&self,
|
||||
viewport: &Viewport,
|
||||
doc_x: f32,
|
||||
doc_y: f32,
|
||||
) -> (f32, f32) {
|
||||
let (canvas_width, canvas_height) = viewport.canvas_size();
|
||||
let _scale = viewport.scale();
|
||||
|
||||
// Convert document point to screen space
|
||||
let (screen_x, screen_y) = viewport.document_to_screen(doc_x, doc_y);
|
||||
|
||||
// Calculate delta to center point
|
||||
let center_x = canvas_width / 2.0;
|
||||
let center_y = canvas_height / 2.0;
|
||||
|
||||
(center_x - screen_x, center_y - screen_y)
|
||||
}
|
||||
|
||||
/// Pan to center a specific document point in the viewport.
|
||||
pub fn pan_to_center_point(&self, viewport: &mut Viewport, doc_x: f32, doc_y: f32) {
|
||||
let (dx, dy) = self.calculate_pan_to_center_point(viewport, doc_x, doc_y);
|
||||
viewport.pan_by(dx, dy);
|
||||
}
|
||||
|
||||
/// Zoom to a specific point (zoom centered on that point).
|
||||
pub fn zoom_at_point(
|
||||
&self,
|
||||
viewport: &mut Viewport,
|
||||
screen_x: f32,
|
||||
screen_y: f32,
|
||||
zoom_factor: f32,
|
||||
) {
|
||||
// Convert screen point to document coordinates before zoom
|
||||
let (doc_x, doc_y) = viewport.screen_to_document(screen_x, screen_y);
|
||||
|
||||
// Apply zoom
|
||||
let old_scale = viewport.scale();
|
||||
let new_scale = old_scale * zoom_factor;
|
||||
viewport.set_scale(new_scale);
|
||||
|
||||
// Convert document point back to screen coordinates after zoom
|
||||
let (new_screen_x, new_screen_y) = viewport.document_to_screen(doc_x, doc_y);
|
||||
|
||||
// Calculate pan adjustment to keep point under cursor
|
||||
let dx = screen_x - new_screen_x;
|
||||
let dy = screen_y - new_screen_y;
|
||||
|
||||
viewport.pan_by(dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_camera_creation() {
|
||||
let camera = Camera::new();
|
||||
assert_eq!(camera.pan_speed, PanSpeed::Normal);
|
||||
assert_eq!(camera.zoom_step, 1.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pan_speed_multiplier() {
|
||||
assert_eq!(PanSpeed::Slow.multiplier(), 0.1);
|
||||
assert_eq!(PanSpeed::Normal.multiplier(), 0.25);
|
||||
assert_eq!(PanSpeed::Fast.multiplier(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pan_direction() {
|
||||
let camera = Camera::new();
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.set_canvas_size(800.0, 600.0);
|
||||
|
||||
camera.pan(&mut viewport, PanDirection::Right);
|
||||
let (pan_x, _) = viewport.pan_offset();
|
||||
assert!(pan_x < 0.0); // Right pan moves content left
|
||||
|
||||
camera.pan(&mut viewport, PanDirection::Left);
|
||||
let (pan_x, _) = viewport.pan_offset();
|
||||
assert_eq!(pan_x, 0.0); // Should cancel out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom() {
|
||||
let camera = Camera::new();
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.set_scale(1.0);
|
||||
|
||||
camera.zoom_in(&mut viewport);
|
||||
assert_eq!(viewport.scale(), 1.25);
|
||||
|
||||
camera.zoom_out(&mut viewport);
|
||||
assert_eq!(viewport.scale(), 1.0);
|
||||
}
|
||||
}
|
||||
8
src/domain/viewport/mod.rs
Normal file
8
src/domain/viewport/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/viewport/mod.rs
|
||||
//
|
||||
// Viewport domain: camera, bounds, and view state management.
|
||||
|
||||
pub mod bounds;
|
||||
pub mod camera;
|
||||
pub mod viewport;
|
||||
300
src/domain/viewport/viewport.rs
Normal file
300
src/domain/viewport/viewport.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/viewport/viewport.rs
|
||||
//
|
||||
// Viewport state and transformations for document viewing.
|
||||
|
||||
/// View mode for document display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
/// Fit entire document in viewport.
|
||||
Fit,
|
||||
/// Display at actual size (1:1 pixel ratio).
|
||||
ActualSize,
|
||||
/// Custom zoom level.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
Self::Fit
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewport state for document display.
|
||||
///
|
||||
/// Manages pan, zoom, and view mode transformations.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Viewport {
|
||||
/// Current view mode.
|
||||
view_mode: ViewMode,
|
||||
/// Pan offset X (in screen pixels).
|
||||
pan_x: f32,
|
||||
/// Pan offset Y (in screen pixels).
|
||||
pan_y: f32,
|
||||
/// Current scale factor.
|
||||
scale: f32,
|
||||
/// Canvas dimensions (viewport size).
|
||||
canvas_width: f32,
|
||||
canvas_height: f32,
|
||||
/// Document dimensions (content size).
|
||||
document_width: f32,
|
||||
document_height: f32,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// Create a new viewport with default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
view_mode: ViewMode::Fit,
|
||||
pan_x: 0.0,
|
||||
pan_y: 0.0,
|
||||
scale: 1.0,
|
||||
canvas_width: 0.0,
|
||||
canvas_height: 0.0,
|
||||
document_width: 0.0,
|
||||
document_height: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the canvas (viewport) dimensions.
|
||||
pub fn set_canvas_size(&mut self, width: f32, height: f32) {
|
||||
self.canvas_width = width;
|
||||
self.canvas_height = height;
|
||||
self.update_scale_if_fit();
|
||||
}
|
||||
|
||||
/// Set the document dimensions.
|
||||
pub fn set_document_size(&mut self, width: f32, height: f32) {
|
||||
self.document_width = width;
|
||||
self.document_height = height;
|
||||
self.update_scale_if_fit();
|
||||
}
|
||||
|
||||
/// Get the current view mode.
|
||||
#[must_use]
|
||||
pub fn view_mode(&self) -> ViewMode {
|
||||
self.view_mode
|
||||
}
|
||||
|
||||
/// Set the view mode.
|
||||
pub fn set_view_mode(&mut self, mode: ViewMode) {
|
||||
self.view_mode = mode;
|
||||
match mode {
|
||||
ViewMode::Fit => {
|
||||
self.reset_pan();
|
||||
self.update_scale_if_fit();
|
||||
}
|
||||
ViewMode::ActualSize => {
|
||||
self.reset_pan();
|
||||
self.scale = 1.0;
|
||||
}
|
||||
ViewMode::Custom => {
|
||||
// Keep current scale and pan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current scale factor.
|
||||
#[must_use]
|
||||
pub fn scale(&self) -> f32 {
|
||||
self.scale
|
||||
}
|
||||
|
||||
/// Set the scale factor (switches to Custom mode).
|
||||
pub fn set_scale(&mut self, scale: f32) {
|
||||
self.scale = scale.max(0.01); // Minimum scale
|
||||
self.view_mode = ViewMode::Custom;
|
||||
}
|
||||
|
||||
/// Zoom in by a factor.
|
||||
pub fn zoom_in(&mut self, factor: f32) {
|
||||
self.set_scale(self.scale * factor);
|
||||
}
|
||||
|
||||
/// Zoom out by a factor.
|
||||
pub fn zoom_out(&mut self, factor: f32) {
|
||||
self.set_scale(self.scale / factor);
|
||||
}
|
||||
|
||||
/// Get pan offset.
|
||||
#[must_use]
|
||||
pub fn pan_offset(&self) -> (f32, f32) {
|
||||
(self.pan_x, self.pan_y)
|
||||
}
|
||||
|
||||
/// Set pan offset.
|
||||
pub fn set_pan(&mut self, x: f32, y: f32) {
|
||||
self.pan_x = x;
|
||||
self.pan_y = y;
|
||||
if self.view_mode == ViewMode::Fit {
|
||||
self.view_mode = ViewMode::Custom;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pan by a delta.
|
||||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||||
self.pan_x += dx;
|
||||
self.pan_y += dy;
|
||||
if self.view_mode == ViewMode::Fit {
|
||||
self.view_mode = ViewMode::Custom;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset pan to center.
|
||||
pub fn reset_pan(&mut self) {
|
||||
self.pan_x = 0.0;
|
||||
self.pan_y = 0.0;
|
||||
}
|
||||
|
||||
/// Get canvas dimensions.
|
||||
#[must_use]
|
||||
pub fn canvas_size(&self) -> (f32, f32) {
|
||||
(self.canvas_width, self.canvas_height)
|
||||
}
|
||||
|
||||
/// Get document dimensions.
|
||||
#[must_use]
|
||||
pub fn document_size(&self) -> (f32, f32) {
|
||||
(self.document_width, self.document_height)
|
||||
}
|
||||
|
||||
/// Get scaled document dimensions.
|
||||
#[must_use]
|
||||
pub fn scaled_document_size(&self) -> (f32, f32) {
|
||||
(
|
||||
self.document_width * self.scale,
|
||||
self.document_height * self.scale,
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate the scale to fit the document in the viewport.
|
||||
#[must_use]
|
||||
pub fn calculate_fit_scale(&self) -> f32 {
|
||||
if self.document_width == 0.0 || self.document_height == 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let width_scale = self.canvas_width / self.document_width;
|
||||
let height_scale = self.canvas_height / self.document_height;
|
||||
|
||||
width_scale.min(height_scale)
|
||||
}
|
||||
|
||||
/// Update scale to fit mode if currently in fit mode.
|
||||
fn update_scale_if_fit(&mut self) {
|
||||
if self.view_mode == ViewMode::Fit {
|
||||
self.scale = self.calculate_fit_scale();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert screen coordinates to document coordinates.
|
||||
#[must_use]
|
||||
pub fn screen_to_document(&self, screen_x: f32, screen_y: f32) -> (f32, f32) {
|
||||
let (scaled_width, scaled_height) = self.scaled_document_size();
|
||||
|
||||
// Calculate document position in canvas
|
||||
let doc_x = (self.canvas_width - scaled_width) / 2.0 + self.pan_x;
|
||||
let doc_y = (self.canvas_height - scaled_height) / 2.0 + self.pan_y;
|
||||
|
||||
// Convert screen to document coordinates
|
||||
let rel_x = screen_x - doc_x;
|
||||
let rel_y = screen_y - doc_y;
|
||||
|
||||
(rel_x / self.scale, rel_y / self.scale)
|
||||
}
|
||||
|
||||
/// Convert document coordinates to screen coordinates.
|
||||
#[must_use]
|
||||
pub fn document_to_screen(&self, doc_x: f32, doc_y: f32) -> (f32, f32) {
|
||||
let (scaled_width, scaled_height) = self.scaled_document_size();
|
||||
|
||||
// Calculate document position in canvas
|
||||
let offset_x = (self.canvas_width - scaled_width) / 2.0 + self.pan_x;
|
||||
let offset_y = (self.canvas_height - scaled_height) / 2.0 + self.pan_y;
|
||||
|
||||
(
|
||||
offset_x + doc_x * self.scale,
|
||||
offset_y + doc_y * self.scale,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the visible bounds of the document in document coordinates.
|
||||
///
|
||||
/// Returns (x, y, width, height) of the visible region.
|
||||
#[must_use]
|
||||
pub fn visible_bounds(&self) -> (f32, f32, f32, f32) {
|
||||
let (top_left_x, top_left_y) = self.screen_to_document(0.0, 0.0);
|
||||
let (bottom_right_x, bottom_right_y) =
|
||||
self.screen_to_document(self.canvas_width, self.canvas_height);
|
||||
|
||||
let x = top_left_x.max(0.0);
|
||||
let y = top_left_y.max(0.0);
|
||||
let width = (bottom_right_x - top_left_x).min(self.document_width - x);
|
||||
let height = (bottom_right_y - top_left_y).min(self.document_height - y);
|
||||
|
||||
(x, y, width, height)
|
||||
}
|
||||
|
||||
/// Reset viewport to default state.
|
||||
pub fn reset(&mut self) {
|
||||
self.view_mode = ViewMode::Fit;
|
||||
self.pan_x = 0.0;
|
||||
self.pan_y = 0.0;
|
||||
self.update_scale_if_fit();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_viewport_creation() {
|
||||
let viewport = Viewport::new();
|
||||
assert_eq!(viewport.view_mode(), ViewMode::Fit);
|
||||
assert_eq!(viewport.scale(), 1.0);
|
||||
assert_eq!(viewport.pan_offset(), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fit_scale_calculation() {
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.set_canvas_size(800.0, 600.0);
|
||||
viewport.set_document_size(1600.0, 1200.0);
|
||||
|
||||
assert_eq!(viewport.calculate_fit_scale(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom() {
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.set_scale(1.0);
|
||||
|
||||
viewport.zoom_in(2.0);
|
||||
assert_eq!(viewport.scale(), 2.0);
|
||||
assert_eq!(viewport.view_mode(), ViewMode::Custom);
|
||||
|
||||
viewport.zoom_out(2.0);
|
||||
assert_eq!(viewport.scale(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coordinate_conversion() {
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.set_canvas_size(800.0, 600.0);
|
||||
viewport.set_document_size(400.0, 300.0);
|
||||
viewport.set_scale(1.0);
|
||||
|
||||
// Document should be centered in canvas
|
||||
let (screen_x, screen_y) = viewport.document_to_screen(0.0, 0.0);
|
||||
assert_eq!(screen_x, 200.0); // (800 - 400) / 2
|
||||
assert_eq!(screen_y, 150.0); // (600 - 300) / 2
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue