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}