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}