ailloli_ui_editor/
viewport.rs

1//! Editor padding, gutter allocation, scrolling, and coordinate conversion.
2
3use ailloli_ui_core::{Point, Rect};
4use ailloli_ui_text::{TextEditState, WrapMode};
5
6use crate::code::GutterConfig;
7use crate::{EditorConfig, EditorStyle, EditorWrapMode};
8
9/// Computes the text content viewport inside the editor bounds.
10///
11/// Applies uniform logical-pixel padding and clamps width/height at zero. The
12/// origin still advances by padding when bounds are smaller or negative.
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_core::Rect;
18/// use ailloli_ui_editor::{editor_content_rect, EditorStyle};
19/// let rect = editor_content_rect(Rect::new(5.0, 7.0, 100.0, 60.0), EditorStyle::default());
20/// assert_eq!(rect, Rect::new(15.0, 17.0, 80.0, 40.0));
21/// ```
22pub fn editor_content_rect(bounds: Rect, style: EditorStyle) -> Rect {
23    Rect::new(
24        bounds.x + style.pad,
25        bounds.y + style.pad,
26        (bounds.w - style.pad * 2.0).max(0.0),
27        (bounds.h - style.pad * 2.0).max(0.0),
28    )
29}
30
31/// Current editor viewport in widget/screen coordinates.
32///
33/// All geometry and scroll values are logical pixels. Soft wrap forces
34/// horizontal scroll to zero; vertical scroll is clamped non-negative.
35///
36/// # Examples
37///
38/// ```
39/// use ailloli_ui_core::Rect;
40/// use ailloli_ui_editor::{EditorConfig, EditorViewport};
41/// use ailloli_ui_text::TextEditState;
42/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 100.0, 60.0), EditorConfig::default(), &TextEditState::new());
43/// assert_eq!(viewport.content_rect, Rect::new(10.0, 10.0, 80.0, 40.0));
44/// assert!(viewport.gutter_rect.is_none());
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct EditorViewport {
48    /// Complete widget bounds.
49    pub bounds: Rect,
50    /// Bounds after editor padding, before optional gutter split.
51    pub content_rect: Rect,
52    /// Text clip/layout viewport after optional gutter allocation.
53    pub text_rect: Rect,
54    /// Optional left gutter, or `None` when disabled/non-positive.
55    pub gutter_rect: Option<Rect>,
56    /// Non-negative horizontal text offset; always zero under soft wrap.
57    pub scroll_x: f32,
58    /// Non-negative vertical content offset.
59    pub scroll_y: f32,
60    /// Wrap policy used to derive width and scrolling behavior.
61    pub wrap_mode: EditorWrapMode,
62}
63
64/// Viewport construction and coordinate conversion operations.
65impl EditorViewport {
66    /// Resolves a viewport without a gutter.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use ailloli_ui_core::Rect;
72    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
73    /// use ailloli_ui_text::TextEditState;
74    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 50.0, 50.0), EditorConfig::default(), &TextEditState::new());
75    /// assert_eq!(viewport.text_rect, viewport.content_rect);
76    /// ```
77    pub fn new(bounds: Rect, config: EditorConfig, edit: &TextEditState) -> Self {
78        Self::with_gutter(bounds, config, edit, None)
79    }
80
81    /// Resolves padding, optional left gutter, and clamped scroll offsets.
82    ///
83    /// An enabled gutter with positive width is clamped to content width; zero,
84    /// negative, or disabled gutter values behave as `None`.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use ailloli_ui_core::Rect;
90    /// use ailloli_ui_editor::{CodeEditorConfig, EditorConfig, EditorViewport};
91    /// use ailloli_ui_text::TextEditState;
92    /// let gutter = CodeEditorConfig::default().gutter;
93    /// let viewport = EditorViewport::with_gutter(Rect::new(0.0, 0.0, 120.0, 60.0), EditorConfig::default(), &TextEditState::new(), Some(gutter));
94    /// assert!(viewport.gutter_rect.is_some());
95    /// assert_eq!(viewport.gutter_rect.unwrap().w, gutter.width);
96    /// ```
97    pub fn with_gutter(
98        bounds: Rect,
99        config: EditorConfig,
100        edit: &TextEditState,
101        gutter: Option<GutterConfig>,
102    ) -> Self {
103        let scroll_x = match config.wrap_mode {
104            EditorWrapMode::SoftWrap => 0.0,
105            EditorWrapMode::NoWrap => edit.scroll_x.max(0.0),
106        };
107        let content_rect = editor_content_rect(bounds, config.style);
108        let (gutter_rect, text_rect) = split_gutter(content_rect, gutter);
109        Self {
110            bounds,
111            content_rect,
112            text_rect,
113            gutter_rect,
114            scroll_x,
115            scroll_y: edit.scroll_y.max(0.0),
116            wrap_mode: config.wrap_mode,
117        }
118    }
119
120    /// Returns the unscrolled text origin minus horizontal scroll.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ailloli_ui_core::Rect;
126    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
127    /// use ailloli_ui_text::TextEditState;
128    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
129    /// assert_eq!(viewport.text_origin_x(), 10.0);
130    /// ```
131    pub fn text_origin_x(self) -> f32 {
132        self.text_rect.x - self.scroll_x
133    }
134
135    /// Returns the fixed top of the text viewport in logical pixels.
136    ///
137    /// Vertical scroll is applied per run, not to this origin.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use ailloli_ui_core::Rect;
143    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
144    /// use ailloli_ui_text::TextEditState;
145    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
146    /// assert_eq!(viewport.text_origin_y(), 10.0);
147    /// ```
148    pub fn text_origin_y(self) -> f32 {
149        self.text_rect.y
150    }
151
152    /// Maps editor wrapping to the text engine's concrete wrap mode.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use ailloli_ui_core::Rect;
158    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
159    /// use ailloli_ui_text::{TextEditState, WrapMode};
160    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
161    /// assert_eq!(viewport.text_wrap_mode(), WrapMode::WordOrAnywhere);
162    /// ```
163    pub fn text_wrap_mode(self) -> WrapMode {
164        match self.wrap_mode {
165            EditorWrapMode::SoftWrap => WrapMode::WordOrAnywhere,
166            EditorWrapMode::NoWrap => WrapMode::NoWrap,
167        }
168    }
169
170    /// Returns the text viewport width for soft wrap, or `None` for no wrap.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ailloli_ui_core::Rect;
176    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
177    /// use ailloli_ui_text::TextEditState;
178    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
179    /// assert_eq!(viewport.max_text_width(), Some(60.0));
180    /// ```
181    pub fn max_text_width(self) -> Option<f32> {
182        match self.wrap_mode {
183            EditorWrapMode::SoftWrap => Some(self.text_rect.w),
184            EditorWrapMode::NoWrap => None,
185        }
186    }
187
188    /// Converts a screen point into scrolled text-local coordinates.
189    ///
190    /// The x result is clamped at zero; y may be negative above the text rect.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use ailloli_ui_core::{Point, Rect};
196    /// use ailloli_ui_editor::{EditorConfig, EditorViewport};
197    /// use ailloli_ui_text::TextEditState;
198    /// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
199    /// assert_eq!(viewport.local_point(Point::new(15.0, 17.0)), (5.0, 7.0));
200    /// ```
201    pub fn local_point(self, pos: Point) -> (f32, f32) {
202        (
203            (pos.x - self.text_rect.x + self.scroll_x).max(0.0),
204            pos.y - self.text_rect.y,
205        )
206    }
207}
208
209/// Splits an enabled positive-width left gutter from the content rectangle.
210fn split_gutter(content_rect: Rect, gutter: Option<GutterConfig>) -> (Option<Rect>, Rect) {
211    let Some(gutter) = gutter.filter(|gutter| gutter.enabled && gutter.width > 0.0) else {
212        return (None, content_rect);
213    };
214    let width = gutter.width.min(content_rect.w.max(0.0));
215    let gutter_rect = Rect::new(content_rect.x, content_rect.y, width, content_rect.h);
216    let text_rect = Rect::new(
217        content_rect.x + width,
218        content_rect.y,
219        (content_rect.w - width).max(0.0),
220        content_rect.h,
221    );
222    (Some(gutter_rect), text_rect)
223}