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