Skip to main content

turbo_vision/views/
view.rs

1// (C) 2025 - Enzo Lombardi
2
3//! View trait - base interface for all UI components with event handling and drawing.
4
5use crate::core::command::CommandId;
6use crate::core::draw::DrawBuffer;
7use crate::core::event::Event;
8use crate::core::geometry::Rect;
9use crate::core::state::{SF_FOCUSED, SF_SHADOW, SHADOW_ATTR, StateFlags, shadow_size};
10use crate::terminal::Terminal;
11use std::io;
12use std::sync::atomic::{AtomicUsize, Ordering};
13
14/// Unique identifier for a view within a Group
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ViewId(usize);
17
18impl ViewId {
19    /// Generate a new unique ViewId
20    pub(crate) fn new() -> Self {
21        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
22        ViewId(NEXT_ID.fetch_add(1, Ordering::Relaxed))
23    }
24
25    /// Get the ViewId as a u16 for embedding in event fields.
26    /// ViewId values are small sequential numbers that fit in u16.
27    #[allow(clippy::cast_possible_truncation)]
28    pub fn as_u16(self) -> u16 {
29        self.0 as u16
30    }
31
32    /// Reconstruct a ViewId from a u16 value.
33    pub fn from_u16(val: u16) -> Self {
34        ViewId(val as usize)
35    }
36}
37
38/// View trait - all UI components implement this
39///
40/// ## Owner/Parent Communication Pattern
41///
42/// Unlike Borland's TView which stores an `owner` pointer to the parent TGroup,
43/// Rust views communicate with parents through event propagation:
44///
45/// **Borland Pattern:**
46/// ```cpp
47/// void TButton::press() {
48///     message(owner, evBroadcast, command, this);
49/// }
50/// ```
51///
52/// **Rust Pattern:**
53/// ```ignore
54/// fn handle_event(&mut self, event: &mut Event) {
55///     // Transform event to send message upward
56///     *event = Event::command(self.command);
57///     // Event bubbles up through Group::handle_event() call stack
58/// }
59/// ```
60///
61/// This achieves the same result (child-to-parent communication) without raw pointers,
62/// using Rust's ownership system and the call stack for context.
63pub trait View {
64    fn bounds(&self) -> Rect;
65    fn set_bounds(&mut self, bounds: Rect);
66    fn draw(&mut self, terminal: &mut Terminal);
67    fn handle_event(&mut self, event: &mut Event);
68    fn can_focus(&self) -> bool {
69        false
70    }
71
72    /// Set focus state - default implementation uses SF_FOCUSED flag
73    /// Views should override only if they need custom focus behavior
74    fn set_focus(&mut self, focused: bool) {
75        self.set_state_flag(SF_FOCUSED, focused);
76    }
77
78    /// Window number for Alt+1..9 selection (Borland: TWindow::number).
79    ///
80    /// `None` for views that aren't numbered windows (Borland wnNoNumber).
81    fn window_number(&self) -> Option<u8> {
82        None
83    }
84
85    /// Check if view is focused - reads SF_FOCUSED flag
86    fn is_focused(&self) -> bool {
87        self.get_state_flag(SF_FOCUSED)
88    }
89
90    /// Get view option flags (OF_SELECTABLE, OF_PRE_PROCESS, OF_POST_PROCESS, etc.)
91    fn options(&self) -> u16 {
92        0
93    }
94
95    /// Set view option flags
96    fn set_options(&mut self, _options: u16) {}
97
98    /// Get view state flags
99    fn state(&self) -> StateFlags {
100        0
101    }
102
103    /// Set view state flags
104    fn set_state(&mut self, _state: StateFlags) {}
105
106    /// Get this view's grow mode flags (Borland: TView::growMode).
107    ///
108    /// Controls how the view's edges move when its parent Group is resized.
109    /// See `GF_GROW_LO_X`, `GF_GROW_LO_Y`, `GF_GROW_HI_X`, `GF_GROW_HI_Y`
110    /// and `GF_GROW_ALL` in `core::state`. The default is `0` (fixed size
111    /// and position relative to the parent's origin), matching Borland's
112    /// default `growMode = 0`.
113    fn grow_mode(&self) -> crate::core::state::GrowFlags {
114        0
115    }
116
117    /// Set this view's grow mode flags (Borland: TView::growMode).
118    ///
119    /// The default implementation is a no-op; views that participate in
120    /// parent-resize layout store the flags in a field and override both
121    /// `grow_mode()` and `set_grow_mode()`.
122    fn set_grow_mode(&mut self, _grow_mode: crate::core::state::GrowFlags) {}
123
124    /// Set or clear specific state flag(s)
125    /// Matches Borland's TView::setState(ushort aState, Boolean enable)
126    /// If enable is true, sets the flag(s), otherwise clears them
127    fn set_state_flag(&mut self, flag: StateFlags, enable: bool) {
128        let current = self.state();
129        if enable {
130            self.set_state(current | flag);
131        } else {
132            self.set_state(current & !flag);
133        }
134    }
135
136    /// Check if specific state flag(s) are set
137    /// Matches Borland's TView::getState(ushort aState)
138    fn get_state_flag(&self, flag: StateFlags) -> bool {
139        (self.state() & flag) == flag
140    }
141
142    /// Check if view has shadow enabled
143    fn has_shadow(&self) -> bool {
144        (self.state() & SF_SHADOW) != 0
145    }
146
147    /// Get bounds including shadow area
148    fn shadow_bounds(&self) -> Rect {
149        let mut bounds = self.bounds();
150        if self.has_shadow() {
151            let ss = shadow_size();
152            bounds.b.x += ss.0;
153            bounds.b.y += ss.1;
154        }
155        bounds
156    }
157
158    /// Update cursor state (called after draw)
159    /// Views that need to show a cursor when focused should override this
160    fn update_cursor(&self, _terminal: &mut Terminal) {
161        // Default: do nothing (cursor stays hidden)
162    }
163
164    /// Zoom (maximize/restore) the view with given maximum bounds
165    /// Matches Borland: TWindow::zoom() toggles between current and max size
166    /// Default implementation does nothing (only windows support zoom)
167    fn zoom(&mut self, _max_bounds: Rect) {
168        // Default: do nothing (only Window implements zoom)
169    }
170
171    /// Validate the view before performing a command (usually closing)
172    /// Matches Borland: TView::valid(ushort command) - returns Boolean
173    /// Returns true if the view's state is valid for the given command
174    /// Used for "Save before closing?" type scenarios and input validation
175    ///
176    /// # Arguments
177    /// * `command` - The command being performed (CM_OK, CM_CANCEL, CM_RELEASED_FOCUS, etc.)
178    ///
179    /// # Returns
180    /// * `true` - View state is valid, command can proceed
181    /// * `false` - View state is invalid, command should be blocked
182    ///
183    /// Default implementation always returns true (no validation)
184    fn valid(&mut self, _command: crate::core::command::CommandId) -> bool {
185        true
186    }
187
188    /// Downcast to concrete type (immutable)
189    /// Allows accessing specific view type methods from trait object
190    fn as_any(&self) -> &dyn std::any::Any {
191        panic!("as_any() not implemented for this view type")
192    }
193
194    /// Downcast to concrete type (mutable)
195    /// Allows accessing specific view type methods from trait object
196    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
197        panic!("as_any_mut() not implemented for this view type")
198    }
199
200    /// Dump this view's region of the terminal buffer to an ANSI file for debugging
201    fn dump_to_file(&self, terminal: &Terminal, path: &str) -> io::Result<()> {
202        let bounds = self.shadow_bounds();
203        terminal.dump_region(
204            bounds.a.x as u16,
205            bounds.a.y as u16,
206            (bounds.b.x - bounds.a.x) as u16,
207            (bounds.b.y - bounds.a.y) as u16,
208            path,
209        )
210    }
211
212    /// Check if this view is a default button (for Enter key handling at Dialog level)
213    /// Corresponds to Borland's TButton::amDefault flag (tbutton.cc line 239)
214    fn is_default_button(&self) -> bool {
215        false
216    }
217
218    /// Get the command ID for this button (if it's a button)
219    /// Returns None if not a button
220    /// Used by Dialog to activate default button on Enter key
221    fn button_command(&self) -> Option<u16> {
222        None
223    }
224
225    /// Set the selection index for listbox views
226    /// Only implemented by ListBox, other views ignore this
227    fn set_list_selection(&mut self, _index: usize) {
228        // Default: do nothing (not a listbox)
229    }
230
231    /// Get the selection index for listbox views
232    /// Only implemented by ListBox, other views return 0
233    fn get_list_selection(&self) -> usize {
234        0
235    }
236
237    /// Get the union rect of previous and current bounds for redrawing
238    /// Matches Borland: TView::locate() calculates union of old and new bounds
239    /// Returns None if the view hasn't moved since last redraw
240    /// Used by Desktop to implement Borland's drawUnderRect pattern
241    fn get_redraw_union(&self) -> Option<Rect> {
242        None // Default: no movement tracking
243    }
244
245    /// Clear movement tracking after redrawing
246    /// Matches Borland: Called after drawUnderRect completes
247    fn clear_move_tracking(&mut self) {
248        // Default: do nothing (no movement tracking)
249    }
250
251    /// Get the end state for modal views
252    /// Matches Borland: TGroup::endState field
253    /// Returns the command ID that ended modal execution (0 if still running)
254    fn get_end_state(&self) -> CommandId {
255        0 // Default: not ended
256    }
257
258    /// Set the end state for modal views
259    /// Called by end_modal() to signal the modal loop should exit
260    fn set_end_state(&mut self, _command: CommandId) {
261        // Default: do nothing (only modal views need this)
262    }
263
264    /// Convert local coordinates to global (screen) coordinates
265    /// Matches Borland: TView::makeGlobal(TPoint source, TPoint& dest)
266    ///
267    /// In Borland, makeGlobal traverses the owner chain and accumulates offsets.
268    /// In this Rust implementation, views store absolute bounds (converted in Group::add()),
269    /// so we simply add the view's origin to the local coordinates.
270    ///
271    /// # Arguments
272    /// * `local_x` - X coordinate relative to view's interior (0,0 = top-left of view)
273    /// * `local_y` - Y coordinate relative to view's interior
274    ///
275    /// # Returns
276    /// Global (screen) coordinates as (x, y) tuple
277    fn make_global(&self, local_x: i16, local_y: i16) -> (i16, i16) {
278        let bounds = self.bounds();
279        (bounds.a.x + local_x, bounds.a.y + local_y)
280    }
281
282    /// Convert global (screen) coordinates to local view coordinates
283    /// Matches Borland: TView::makeLocal(TPoint source, TPoint& dest)
284    ///
285    /// In Borland, makeLocal is the inverse of makeGlobal, converting screen
286    /// coordinates back to view-relative coordinates.
287    ///
288    /// # Arguments
289    /// * `global_x` - X coordinate in screen space
290    /// * `global_y` - Y coordinate in screen space
291    ///
292    /// # Returns
293    /// Local coordinates as (x, y) tuple, where (0,0) is the view's top-left
294    fn make_local(&self, global_x: i16, global_y: i16) -> (i16, i16) {
295        let bounds = self.bounds();
296        (global_x - bounds.a.x, global_y - bounds.a.y)
297    }
298
299    /// Draw shadow for this view
300    /// Draws a shadow offset dynamically based on terminal cell aspect ratio
301    /// Shadow is semi-transparent - darkens the underlying content by 50%
302    /// This matches the Borland Turbo Vision behavior more closely
303    fn draw_shadow(&self, terminal: &mut Terminal) {
304        use crate::core::palette::Attr;
305
306        const SHADOW_FACTOR: f32 = 0.5; // Darken to 50% of original brightness
307
308        let bounds = self.bounds();
309        let ss = shadow_size();
310        let mut buf = DrawBuffer::new(ss.0 as usize);
311
312        // Draw right edge shadow (ss.0 columns wide, offset by ss.1 vertically)
313        // Read existing cells and darken them for semi-transparency
314        for y in (bounds.a.y + ss.1)..(bounds.b.y + ss.1) {
315            for i in 0..ss.0 {
316                let x = bounds.b.x + i;
317
318                // Read the existing cell at this position
319                if let Some(existing_cell) = terminal.read_cell(x, y) {
320                    // Darken the existing cell's attribute
321                    let darkened_attr = existing_cell.attr.darken(SHADOW_FACTOR);
322                    buf.put_char(i as usize, existing_cell.ch, darkened_attr);
323                } else {
324                    // Out of bounds - use default shadow
325                    let default_attr = Attr::from_u8(SHADOW_ATTR);
326                    buf.put_char(i as usize, ' ', default_attr);
327                }
328            }
329            write_line_to_terminal(terminal, bounds.b.x, y, &buf);
330        }
331
332        // Draw bottom edge shadow (offset by ss.0 horizontally, excludes right shadow area to prevent double-darkening)
333        let bottom_width = (bounds.b.x - bounds.a.x - ss.0) as usize;
334        let mut bottom_buf = DrawBuffer::new(bottom_width);
335
336        let shadow_y = bounds.b.y;
337        for i in 0..bottom_width {
338            let x = bounds.a.x + ss.0 + i as i16;
339
340            // Read the existing cell at this position
341            if let Some(existing_cell) = terminal.read_cell(x, shadow_y) {
342                // Darken the existing cell's attribute
343                let darkened_attr = existing_cell.attr.darken(SHADOW_FACTOR);
344                bottom_buf.put_char(i, existing_cell.ch, darkened_attr);
345            } else {
346                // Out of bounds - use default shadow
347                let default_attr = Attr::from_u8(SHADOW_ATTR);
348                bottom_buf.put_char(i, ' ', default_attr);
349            }
350        }
351        write_line_to_terminal(terminal, bounds.a.x + ss.0, bounds.b.y, &bottom_buf);
352    }
353
354    /// Get the linked control ViewId for labels
355    /// Matches Borland: TLabel::link field
356    /// Returns Some(ViewId) if this is a label with a linked control, None otherwise
357    /// Used by Group to implement focus transfer when clicking labels
358    fn label_link(&self) -> Option<ViewId> {
359        None // Default: not a label or no link
360    }
361
362    /// Initialize internal owner pointers after view is added to parent and won't move
363    /// This is called by parent's add() method after the view is in its final position
364    /// Views that contain other views by value should override this to set up owner chains
365    /// Default implementation does nothing
366    fn init_after_add(&mut self) {
367        // Default: no action needed
368    }
369
370    /// Constrain view bounds to parent/owner bounds
371    /// Used after positioning (e.g., centering) to ensure view stays within valid area
372    /// Matches Borland: TView::locate() constrains position to owner bounds
373    fn constrain_to_parent_bounds(&mut self) {
374        // Default: no action needed (only windows need this)
375    }
376
377    /// Set the QCell-based palette chain node for this view.
378    /// Called by parent (Group/Window) during draw to establish the safe owner chain.
379    fn set_palette_chain(&mut self, _node: Option<crate::core::palette_chain::PaletteChainNode>) {
380        // Default: do nothing (views that need palette chain will override)
381    }
382
383    /// Get the QCell-based palette chain node for this view.
384    /// Used by `map_color()` to safely walk the owner chain.
385    fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
386        None // Default: no palette chain
387    }
388
389    /// Set the parent's bounds for drag/resize limit resolution.
390    /// Called by Desktop when adding windows.
391    fn set_parent_bounds(&mut self, _bounds: crate::core::geometry::Rect) {
392        // Default: do nothing (only Window needs this)
393    }
394
395    /// Get this view's palette for the Borland indirect palette system
396    /// Matches Borland: TView::getPalette()
397    ///
398    /// Returns a Palette that maps this view's logical color indices to the parent's indices.
399    /// When resolving colors, the system walks up the owner chain remapping through palettes
400    /// until reaching the Application which has actual color attributes.
401    ///
402    /// # Returns
403    /// * `Some(Palette)` - This view has a palette for color remapping
404    /// * `None` - This view has no palette (transparent to color mapping)
405    fn get_palette(&self) -> Option<crate::core::palette::Palette>;
406
407    /// Map a logical color index to an actual color attribute
408    /// Matches Borland: TView::mapColor(uchar index)
409    ///
410    /// Walks up the owner chain, remapping the color index through each view's palette
411    /// until reaching a view with no owner (Application), which provides actual attributes.
412    ///
413    /// # Arguments
414    /// * `color_index` - Logical color index (1-based, 0 = error color)
415    ///
416    /// # Returns
417    /// The final color attribute
418    fn map_color(&self, color_index: u8) -> crate::core::palette::Attr {
419        use crate::core::palette::{Attr, palettes};
420
421        // Borland's errorAttr = 0xCF (Light Red/Magenta background, White foreground)
422        const ERROR_ATTR: u8 = 0xCF;
423
424        if color_index == 0 {
425            return Attr::from_u8(ERROR_ATTR);
426        }
427
428        let mut color = color_index;
429
430        // Step 1: Remap through this view's own palette
431        if let Some(palette) = self.get_palette() {
432            if !palette.is_empty() {
433                if color as usize > palette.len() {
434                    return Attr::from_u8(ERROR_ATTR);
435                }
436                color = palette.get(color as usize);
437                if color == 0 {
438                    return Attr::from_u8(ERROR_ATTR);
439                }
440            }
441        }
442
443        // Step 2: Walk up the owner chain via QCell-based palette chain.
444        // Matches Borland: TView::mapColor() traverses owner->getPalette() up to
445        // TApplication. Views without a palette (get_palette returns None) are
446        // transparent. The chain stops when there's no parent.
447        if let Some(chain_node) = self.get_palette_chain() {
448            color = chain_node.remap_color(color);
449            if color == 0 {
450                return Attr::from_u8(ERROR_ATTR);
451            }
452        }
453        // Views without a palette chain (top-level views like MenuBar, StatusLine)
454        // skip the chain walk and go directly to the app palette.
455
456        // Step 3: Resolve through application palette (1-indexed)
457        let app_palette_data = palettes::get_app_palette();
458        let app_index = (color as usize).wrapping_sub(1);
459        if app_index < app_palette_data.len() {
460            let final_color = app_palette_data[app_index];
461            if final_color == 0 {
462                return Attr::from_u8(ERROR_ATTR);
463            }
464            Attr::from_u8(final_color)
465        } else {
466            Attr::from_u8(ERROR_ATTR)
467        }
468    }
469}
470
471/// Trait for views that need idle processing (animations, timers, etc.)
472/// These views have their idle() method called periodically even during modal dialogs,
473/// matching Borland's TProgram::idle() behavior which continues running during execView().
474///
475/// # Examples
476///
477/// ```ignore
478/// use turbo_vision::views::{View, IdleView};
479/// use turbo_vision::terminal::Terminal;
480/// use std::time::Instant;
481///
482/// struct AnimatedWidget {
483///     position: usize,
484///     last_update: Instant,
485///     // ... other View fields
486/// }
487///
488/// impl IdleView for AnimatedWidget {
489///     fn idle(&mut self) {
490///         if self.last_update.elapsed().as_millis() > 100 {
491///             self.position = (self.position + 1) % 10;
492///             self.last_update = Instant::now();
493///         }
494///     }
495/// }
496/// ```
497pub trait IdleView: View {
498    /// Called periodically to update animation state, timers, etc.
499    /// Matches Borland: TProgram::idle() continues running even during modal dialogs
500    fn idle(&mut self);
501}
502
503/// Helper to draw a line to the terminal
504pub fn write_line_to_terminal(terminal: &mut Terminal, x: i16, y: i16, buf: &DrawBuffer) {
505    if y < 0 || y >= terminal.size().1 {
506        return;
507    }
508    terminal.write_line(x.max(0) as u16, y as u16, &buf.data);
509}
510
511/// Draw shadow for arbitrary bounds (for non-view elements like temporary dropdowns)
512///
513/// Note: Views should use the `draw_shadow()` trait method instead, which gets bounds
514/// from `self.bounds()` following the principle "bounds should not be passed down".
515/// This standalone function is only for special cases where you're drawing shadows
516/// for elements that aren't views (e.g., temporary dropdowns).
517pub fn draw_shadow_bounds(terminal: &mut Terminal, bounds: Rect) {
518    use crate::core::palette::Attr;
519
520    const SHADOW_FACTOR: f32 = 0.5; // Darken to 50% of original brightness
521
522    let ss = shadow_size();
523    let mut buf = DrawBuffer::new(ss.0 as usize);
524
525    // Draw right edge shadow (ss.0 columns wide, offset by ss.1 vertically)
526    // Read existing cells and darken them for semi-transparency
527    for y in (bounds.a.y + ss.1)..(bounds.b.y + ss.1) {
528        for i in 0..ss.0 {
529            let x = bounds.b.x + i;
530
531            // Read the existing cell at this position
532            if let Some(existing_cell) = terminal.read_cell(x, y) {
533                // Darken the existing cell's attribute
534                let darkened_attr = existing_cell.attr.darken(SHADOW_FACTOR);
535                buf.put_char(i as usize, existing_cell.ch, darkened_attr);
536            } else {
537                // Out of bounds - use default shadow
538                let default_attr = Attr::from_u8(SHADOW_ATTR);
539                buf.put_char(i as usize, ' ', default_attr);
540            }
541        }
542        write_line_to_terminal(terminal, bounds.b.x, y, &buf);
543    }
544
545    // Draw bottom edge shadow (offset by ss.0 horizontally, excludes right shadow area to prevent double-darkening)
546    let bottom_width = (bounds.b.x - bounds.a.x - ss.0) as usize;
547    let mut bottom_buf = DrawBuffer::new(bottom_width);
548
549    let shadow_y = bounds.b.y;
550    for i in 0..bottom_width {
551        let x = bounds.a.x + ss.0 + i as i16;
552
553        // Read the existing cell at this position
554        if let Some(existing_cell) = terminal.read_cell(x, shadow_y) {
555            // Darken the existing cell's attribute
556            let darkened_attr = existing_cell.attr.darken(SHADOW_FACTOR);
557            bottom_buf.put_char(i, existing_cell.ch, darkened_attr);
558        } else {
559            // Out of bounds - use default shadow
560            let default_attr = Attr::from_u8(SHADOW_ATTR);
561            bottom_buf.put_char(i, ' ', default_attr);
562        }
563    }
564    write_line_to_terminal(terminal, bounds.a.x + ss.0, bounds.b.y, &bottom_buf);
565}