Skip to main content

turbo_vision/core/
command_set.rs

1// (C) 2025 - Enzo Lombardi
2//! Command Set System
3//!
4//! Implements Borland Turbo Vision's command enable/disable system.
5//! Commands are stored as a bitfield allowing efficient enable/disable operations.
6//!
7//! ## Architecture
8//!
9//! - **`CommandSet`**: Bitfield storing enabled/disabled state for up to 65,536 commands
10//! - **Global State**: Stored in Application, accessible via View trait methods
11//! - **Automatic Updates**: Buttons auto-enable/disable when command set changes
12//! - **Broadcast Pattern**: cmCommandSetChanged notifies all views of changes
13//!
14//! ## Reference
15//!
16//! Based on Borland's implementation:
17//! - `/include/tv/cmdset.h` (lines 14-84)
18//! - `/classes/tcommand.cc` (lines 1-268)
19
20use crate::core::command::CommandId;
21use std::cell::RefCell;
22
23/// Maximum number of commands supported (65,536)
24/// Matches Borland: `MAX_COMMANDS` = 32 * 2048
25pub const MAX_COMMANDS: usize = 32 * 2048;
26
27// Global command set - matches Borland's TView::curCommandSet (tview.cc:67)
28// Uses thread_local to avoid Sync requirement while maintaining global accessibility
29//
30// ## Thread-Local Global Command Set
31//
32// For compatibility with Borland TV, commands are managed through a thread-local
33// global command set. This design has the following implications:
34//
35// - **Per-thread state**: Each thread has its own independent command set
36// - **Test isolation**: Multiple Application instances in different threads don't interfere
37// - **No synchronization overhead**: No need for Mutex/Arc since state is thread-local
38// - **Single-threaded TUI**: Appropriate for terminal applications (always single-threaded)
39//
40// **Usage:**
41// ```rust
42// use turbo_vision::core::command_set::{enable_command, disable_command, command_enabled};
43// use turbo_vision::core::command::CM_SAVE;
44//
45// // Enable a command
46// enable_command(CM_SAVE);
47//
48// // Check if command is enabled
49// if command_enabled(CM_SAVE) {
50//     // Save operation is available
51// }
52//
53// // Disable a command
54// disable_command(CM_SAVE);
55// ```
56//
57// **Alternative Design (Future):**
58// For applications requiring explicit command set management, consider
59// passing CommandSet through Application:
60// ```rust,ignore
61// pub struct Application {
62//     command_set: CommandSet,
63//     // ...
64// }
65// ```
66thread_local! {
67    static GLOBAL_COMMAND_SET: RefCell<CommandSet> = RefCell::new(CommandSet::with_all_enabled());
68    static COMMAND_SET_CHANGED: RefCell<bool> = const { RefCell::new(false) };
69}
70
71/// Check if a command is currently enabled (global query)
72/// Matches Borland: `TView::commandEnabled(ushort` command) (tview.cc:142-147)
73pub fn command_enabled(command: CommandId) -> bool {
74    GLOBAL_COMMAND_SET.with(|cs| cs.borrow().has(command))
75}
76
77/// Enable a command in the global command set
78/// Matches Borland: `TView::enableCommand(ushort` command) (tview.cc:384-389)
79pub fn enable_command(command: CommandId) {
80    GLOBAL_COMMAND_SET.with(|cs| {
81        let mut set = cs.borrow_mut();
82        if !set.has(command) {
83            COMMAND_SET_CHANGED.with(|changed| *changed.borrow_mut() = true);
84        }
85        set.enable_command(command);
86    });
87}
88
89/// Disable a command in the global command set
90/// Matches Borland: `TView::disableCommand(ushort` command) (tview.cc:161-166)
91pub fn disable_command(command: CommandId) {
92    GLOBAL_COMMAND_SET.with(|cs| {
93        let mut set = cs.borrow_mut();
94        if set.has(command) {
95            COMMAND_SET_CHANGED.with(|changed| *changed.borrow_mut() = true);
96        }
97        set.disable_command(command);
98    });
99}
100
101/// A copy of the global command set, to put back later with
102/// [`set_commands`]. Matches Borland: `TView::getCommands`.
103pub fn get_commands() -> CommandSet {
104    GLOBAL_COMMAND_SET.with(|cs| cs.borrow().clone())
105}
106
107/// Replace the global command set, flagging a change if it differs.
108/// Matches Borland: `TView::setCommands`.
109pub fn set_commands(commands: CommandSet) {
110    GLOBAL_COMMAND_SET.with(|cs| {
111        let mut set = cs.borrow_mut();
112        if *set != commands {
113            COMMAND_SET_CHANGED.with(|changed| *changed.borrow_mut() = true);
114        }
115        *set = commands;
116    });
117}
118
119/// Check if command set has changed (needs broadcast)
120/// Matches Borland: `TView::commandSetChanged` (tview.cc:51)
121pub fn command_set_changed() -> bool {
122    COMMAND_SET_CHANGED.with(|changed| *changed.borrow())
123}
124
125/// Clear the command set changed flag
126/// Called after broadcasting `CM_COMMAND_SET_CHANGED`
127pub fn clear_command_set_changed() {
128    COMMAND_SET_CHANGED.with(|changed| *changed.borrow_mut() = false);
129}
130
131/// Initialize the global command set with specific disabled commands
132/// Matches Borland: `initCommands()` (tview.cc:58-68)
133pub fn init_command_set() {
134    use crate::core::command::{CM_CLOSE, CM_NEXT, CM_PREV, CM_RESIZE, CM_ZOOM};
135
136    GLOBAL_COMMAND_SET.with(|cs| {
137        let mut set = cs.borrow_mut();
138        *set = CommandSet::with_all_enabled();
139        // Matches Borland initCommands (tview.cc): window commands start
140        // disabled and are enabled when a window can accept them
141        for cmd in [CM_CLOSE, CM_ZOOM, CM_RESIZE, CM_NEXT, CM_PREV] {
142            set.disable_command(cmd);
143        }
144    });
145    COMMAND_SET_CHANGED.with(|changed| *changed.borrow_mut() = false);
146}
147
148/// Number of 32-bit words needed to store command bits
149const COMMANDS_COUNT: usize = MAX_COMMANDS / 32;
150
151/// Command set bitfield for tracking enabled/disabled commands
152///
153/// Matches Borland's `TCommandSet` (cmdset.h:14-84)
154/// Uses a bitfield array where each command ID is a bit position
155#[derive(Clone, PartialEq)]
156pub struct CommandSet {
157    /// Bitfield storage: 2048 words * 32 bits = 65,536 command bits
158    /// Matches Borland: uint32 *cmds
159    cmds: Box<[u32; COMMANDS_COUNT]>,
160}
161
162impl CommandSet {
163    /// Create a new command set with all commands disabled
164    ///
165    /// Matches Borland: `TCommandSet::TCommandSet()` (tcommand.cc:41-48)
166    pub fn new() -> Self {
167        Self {
168            cmds: Box::new([0; COMMANDS_COUNT]),
169        }
170    }
171
172    /// Create a command set with all commands enabled
173    ///
174    /// Matches Borland: `TCommandSet::enableAllCommands()` (tcommand.cc:132-137)
175    pub fn with_all_enabled() -> Self {
176        Self {
177            cmds: Box::new([0xFFFFFFFF; COMMANDS_COUNT]),
178        }
179    }
180
181    /// Check if a command is enabled
182    ///
183    /// Matches Borland: `TCommandSet::has(int` cmd) (tcommand.cc:108-112)
184    pub fn has(&self, command: CommandId) -> bool {
185        let cmd = command as usize;
186        if cmd >= MAX_COMMANDS {
187            // Commands >= MAX_COMMANDS are always enabled
188            return true;
189        }
190        let word_index = cmd / 32;
191        let bit_mask = 1u32 << (cmd & 0x1F);
192        (self.cmds[word_index] & bit_mask) != 0
193    }
194
195    /// Enable a single command
196    ///
197    /// Matches Borland: `TCommandSet::enableCmd(int` cmd) (tcommand.cc:139-145)
198    pub fn enable_command(&mut self, command: CommandId) {
199        let cmd = command as usize;
200        if cmd >= MAX_COMMANDS {
201            return;
202        }
203        let word_index = cmd / 32;
204        let bit_mask = 1u32 << (cmd & 0x1F);
205        self.cmds[word_index] |= bit_mask;
206    }
207
208    /// Disable a single command
209    ///
210    /// Matches Borland: `TCommandSet::disableCmd(int` cmd) (tcommand.cc:180-186)
211    pub fn disable_command(&mut self, command: CommandId) {
212        let cmd = command as usize;
213        if cmd >= MAX_COMMANDS {
214            return;
215        }
216        let word_index = cmd / 32;
217        let bit_mask = 1u32 << (cmd & 0x1F);
218        self.cmds[word_index] &= !bit_mask;
219    }
220
221    /// Enable a range of commands (inclusive)
222    ///
223    /// Matches Borland: `TCommandSet::enableCmd(int` cmdStart, int cmdEnd) (tcommand.cc:147-179)
224    pub fn enable_range(&mut self, cmd_start: CommandId, cmd_end: CommandId) {
225        let start = cmd_start as usize;
226        let end = cmd_end as usize;
227
228        // Inclusive range like Borland's loops: a single-command range
229        // (start == end) is valid, and end is clamped rather than rejected
230        if start >= MAX_COMMANDS || end < start {
231            return;
232        }
233        let end = end.min(MAX_COMMANDS - 1);
234
235        let word_start = start / 32;
236        let word_end = end / 32;
237
238        // Both in the same word
239        if word_start == word_end {
240            for bit in (start & 0x1F)..=(end & 0x1F) {
241                self.cmds[word_start] |= 1u32 << bit;
242            }
243            return;
244        }
245
246        // Set partial bits in first word
247        for bit in (start & 0x1F)..32 {
248            self.cmds[word_start] |= 1u32 << bit;
249        }
250
251        // Set all bits in middle words
252        for word in (word_start + 1)..word_end {
253            self.cmds[word] = 0xFFFFFFFF;
254        }
255
256        // Set partial bits in last word
257        for bit in 0..=(end & 0x1F) {
258            self.cmds[word_end] |= 1u32 << bit;
259        }
260    }
261
262    /// Disable a range of commands (inclusive)
263    ///
264    /// Matches Borland: `TCommandSet::disableCmd(int` cmdStart, int cmdEnd) (tcommand.cc:188-220)
265    pub fn disable_range(&mut self, cmd_start: CommandId, cmd_end: CommandId) {
266        let start = cmd_start as usize;
267        let end = cmd_end as usize;
268
269        // Inclusive range like Borland's loops: a single-command range
270        // (start == end) is valid, and end is clamped rather than rejected
271        if start >= MAX_COMMANDS || end < start {
272            return;
273        }
274        let end = end.min(MAX_COMMANDS - 1);
275
276        let word_start = start / 32;
277        let word_end = end / 32;
278
279        // Both in the same word
280        if word_start == word_end {
281            for bit in (start & 0x1F)..=(end & 0x1F) {
282                self.cmds[word_start] &= !(1u32 << bit);
283            }
284            return;
285        }
286
287        // Clear partial bits in first word
288        for bit in (start & 0x1F)..32 {
289            self.cmds[word_start] &= !(1u32 << bit);
290        }
291
292        // Clear all bits in middle words
293        for word in (word_start + 1)..word_end {
294            self.cmds[word] = 0;
295        }
296
297        // Clear partial bits in last word
298        for bit in 0..=(end & 0x1F) {
299            self.cmds[word_end] &= !(1u32 << bit);
300        }
301    }
302
303    /// Enable all commands in another command set
304    ///
305    /// Matches Borland: `TCommandSet::enableCmd(const` `TCommandSet`&) (tcommand.cc:222-228)
306    pub fn enable_set(&mut self, other: &CommandSet) {
307        for i in 0..COMMANDS_COUNT {
308            self.cmds[i] |= other.cmds[i];
309        }
310    }
311
312    /// Disable all commands in another command set
313    ///
314    /// Matches Borland: `TCommandSet::disableCmd(const` `TCommandSet`&) (tcommand.cc:230-236)
315    pub fn disable_set(&mut self, other: &CommandSet) {
316        for i in 0..COMMANDS_COUNT {
317            self.cmds[i] &= !other.cmds[i];
318        }
319    }
320
321    /// Enable all commands
322    ///
323    /// Matches Borland: `TCommandSet::enableAllCommands()` (tcommand.cc:132-137)
324    pub fn enable_all(&mut self) {
325        self.cmds.fill(0xFFFFFFFF);
326    }
327
328    /// Check if command set is empty (all commands disabled)
329    ///
330    /// Matches Borland: `TCommandSet::isEmpty()` (tcommand.cc:114-125)
331    pub fn is_empty(&self) -> bool {
332        self.cmds.iter().all(|&word| word == 0)
333    }
334
335    /// Perform bitwise AND with another command set
336    ///
337    /// Matches Borland: `TCommandSet::operator&=(const` `TCommandSet`&) (tcommand.cc:259-268)
338    pub fn intersect(&mut self, other: &CommandSet) {
339        for i in 0..COMMANDS_COUNT {
340            self.cmds[i] &= other.cmds[i];
341        }
342    }
343
344    /// Perform bitwise OR with another command set
345    ///
346    /// Matches Borland: `TCommandSet::operator|=(const` `TCommandSet`&) (tcommand.cc:249-257)
347    pub fn union(&mut self, other: &CommandSet) {
348        for i in 0..COMMANDS_COUNT {
349            self.cmds[i] |= other.cmds[i];
350        }
351    }
352}
353
354impl Default for CommandSet {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_enable_disable_single() {
366        let mut cs = CommandSet::new();
367        assert!(!cs.has(10));
368
369        cs.enable_command(10);
370        assert!(cs.has(10));
371
372        cs.disable_command(10);
373        assert!(!cs.has(10));
374    }
375
376    #[test]
377    fn test_enable_range() {
378        let mut cs = CommandSet::new();
379        cs.enable_range(10, 20);
380
381        assert!(!cs.has(9));
382        assert!(cs.has(10));
383        assert!(cs.has(15));
384        assert!(cs.has(20));
385        assert!(!cs.has(21));
386    }
387
388    #[test]
389    fn test_enable_all() {
390        let mut cs = CommandSet::new();
391        cs.enable_all();
392
393        assert!(cs.has(0));
394        assert!(cs.has(100));
395        assert!(cs.has(65535));
396    }
397
398    #[test]
399    fn test_is_empty() {
400        let mut cs = CommandSet::new();
401        assert!(cs.is_empty());
402
403        cs.enable_command(50);
404        assert!(!cs.is_empty());
405    }
406
407    #[test]
408    fn test_commands_default_disabled() {
409        // New command set has all commands disabled by default
410        let cs = CommandSet::new();
411        assert!(!cs.has(0));
412        assert!(!cs.has(100));
413        assert!(!cs.has(1000));
414        assert!(!cs.has(60000));
415        assert!(!cs.has(65535)); // Maximum u16 value
416    }
417
418    #[test]
419    fn test_single_command_range_and_clamped_end() {
420        // Regression: enable_range(n, n) used to be a silent no-op
421        let mut set = CommandSet::new();
422        set.enable_range(42, 42);
423        assert!(set.has(42));
424        set.disable_range(42, 42);
425        assert!(!set.has(42));
426
427        // A range ending at the last command is clamped, not rejected
428        let mut set = CommandSet::new();
429        set.enable_range(65530, 65535);
430        assert!(set.has(65530));
431        assert!(set.has(65535));
432    }
433
434    #[test]
435    fn test_init_command_set_disables_window_commands() {
436        use crate::core::command::{CM_CLOSE, CM_NEXT, CM_PREV, CM_RESIZE, CM_ZOOM};
437        init_command_set();
438        for cmd in [CM_CLOSE, CM_ZOOM, CM_RESIZE, CM_NEXT, CM_PREV] {
439            assert!(!command_enabled(cmd), "cmd {cmd} should start disabled");
440        }
441        assert!(command_enabled(crate::core::command::CM_QUIT));
442        // Restore a fully-enabled set for other tests sharing the thread-local
443        init_command_set();
444        enable_command(CM_CLOSE);
445    }
446}