scarab_plugin_api/host_bindings.rs
1//! ECS-safe host bindings for Fusabi plugins
2//!
3//! This module provides host bindings that allow Fusabi-powered plugins to interact
4//! with Scarab's ECS (Bevy) architecture without direct World access. All interactions
5//! go through message passing and the plugin host system.
6//!
7//! # Architecture
8//!
9//! Plugins communicate via `PluginAction` events which are processed by the client's
10//! plugin host system. This provides:
11//!
12//! - **Safety**: No direct ECS/World mutation from plugin code
13//! - **Sandboxing**: Per-plugin capability flags and quotas
14//! - **Rate limiting**: Protection against runaway plugins
15//!
16//! # Example (Fusabi Script)
17//!
18//! ```fsharp
19//! module MyPlugin
20//!
21//! open Scarab.Host
22//!
23//! [<OnLoad>]
24//! let onLoad (ctx: PluginContext) =
25//! // Register a focusable region
26//! Host.registerFocusable ctx {
27//! X = 10us
28//! Y = 5us
29//! Width = 20us
30//! Height = 1us
31//! Label = "Click me"
32//! Action = OpenUrl "https://example.com"
33//! }
34//!
35//! // Enter hint mode
36//! Host.enterHintMode ctx
37//! ```
38//!
39//! # Safety Constraints
40//!
41//! All host bindings enforce safety constraints:
42//!
43//! | Constraint | Default | Description |
44//! |------------|---------|-------------|
45//! | `max_focusables` | 50 | Max focusables per plugin |
46//! | `max_overlays` | 10 | Max overlays per plugin |
47//! | `max_status_items` | 5 | Max status bar items per plugin |
48//! | `rate_limit` | 10/sec | Actions per second |
49//! | `bounds_check` | enabled | Coordinate validation |
50//!
51//! See [`HostBindingLimits`] for configuration.
52
53use crate::context::PluginContext;
54use crate::error::{PluginError, Result};
55use crate::navigation::{
56 validate_focusable, PluginFocusable, PluginFocusableAction, PluginNavCapabilities,
57};
58use crate::types::{JumpDirection, OverlayConfig, StatusBarItem};
59use parking_lot::Mutex;
60use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
61use std::time::Instant;
62
63/// Default rate limit (actions per second)
64pub const DEFAULT_RATE_LIMIT: u32 = 10;
65
66/// Default maximum focusables per plugin
67pub const DEFAULT_MAX_FOCUSABLES: usize = 50;
68
69/// Default maximum overlays per plugin
70pub const DEFAULT_MAX_OVERLAYS: usize = 10;
71
72/// Default maximum status items per plugin
73pub const DEFAULT_MAX_STATUS_ITEMS: usize = 5;
74
75/// Configuration limits for host bindings
76///
77/// These limits protect the host from misbehaving plugins by capping
78/// resource usage and action rates.
79#[derive(Debug, Clone)]
80pub struct HostBindingLimits {
81 /// Maximum focusable regions a plugin can register
82 pub max_focusables: usize,
83 /// Maximum overlays a plugin can spawn
84 pub max_overlays: usize,
85 /// Maximum status bar items a plugin can add
86 pub max_status_items: usize,
87 /// Actions per second rate limit
88 pub rate_limit: u32,
89 /// Enable coordinate bounds checking
90 pub bounds_check: bool,
91 /// Maximum terminal coordinate (x or y)
92 pub max_coordinate: u16,
93}
94
95impl Default for HostBindingLimits {
96 fn default() -> Self {
97 Self {
98 max_focusables: DEFAULT_MAX_FOCUSABLES,
99 max_overlays: DEFAULT_MAX_OVERLAYS,
100 max_status_items: DEFAULT_MAX_STATUS_ITEMS,
101 rate_limit: DEFAULT_RATE_LIMIT,
102 bounds_check: true,
103 max_coordinate: 1000,
104 }
105 }
106}
107
108/// Navigation style configuration
109///
110/// Allows plugins to select their preferred navigation keymap and visual style.
111#[derive(Debug, Clone, PartialEq, Eq, Default)]
112pub enum NavStyle {
113 /// Default Vimium-style hints (lowercase letters)
114 #[default]
115 Vimium,
116 /// Uppercase letter hints
117 VimiumUppercase,
118 /// Numeric hints (1, 2, 3, ...)
119 Numeric,
120 /// Home row keys only (asdfghjkl)
121 HomeRow,
122 /// Custom character set
123 Custom(String),
124}
125
126impl NavStyle {
127 /// Get the hint characters for this style
128 pub fn hint_chars(&self) -> &str {
129 match self {
130 NavStyle::Vimium => "sadfjklewcmpgh",
131 NavStyle::VimiumUppercase => "SADFJKLEWCMPGH",
132 NavStyle::Numeric => "1234567890",
133 NavStyle::HomeRow => "asdfghjkl",
134 NavStyle::Custom(chars) => chars,
135 }
136 }
137}
138
139/// Navigation keymap configuration
140///
141/// Allows plugins to select or customize the navigation keybindings.
142#[derive(Debug, Clone, PartialEq, Eq, Default)]
143pub enum NavKeymap {
144 /// Default keymap (f=hints, Esc=cancel, Enter=confirm)
145 #[default]
146 Default,
147 /// Vim-style keymap
148 Vim,
149 /// Emacs-style keymap
150 Emacs,
151 /// Custom keymap (key -> action mappings)
152 Custom(Vec<(String, String)>),
153}
154
155/// Rate limiter state for a single plugin
156#[derive(Debug)]
157pub struct PluginRateLimiter {
158 limit: u32,
159 count: AtomicU32,
160 window_start: Mutex<Instant>,
161}
162
163impl PluginRateLimiter {
164 /// Create a new rate limiter
165 pub fn new(limit: u32) -> Self {
166 Self {
167 limit,
168 count: AtomicU32::new(0),
169 window_start: Mutex::new(Instant::now()),
170 }
171 }
172
173 /// Check if an action is allowed, incrementing the counter if so
174 pub fn check(&self) -> Result<()> {
175 let now = Instant::now();
176
177 // Check if we need to reset the window
178 {
179 let mut window = self.window_start.lock();
180 if now.duration_since(*window).as_secs() >= 1 {
181 *window = now;
182 self.count.store(0, Ordering::SeqCst);
183 }
184 }
185
186 let current = self.count.fetch_add(1, Ordering::SeqCst);
187 if current >= self.limit {
188 self.count.fetch_sub(1, Ordering::SeqCst); // Undo the increment
189 return Err(PluginError::RateLimitExceeded {
190 current: current + 1,
191 limit: self.limit,
192 });
193 }
194
195 Ok(())
196 }
197
198 /// Reset the rate limiter
199 pub fn reset(&self) {
200 *self.window_start.lock() = Instant::now();
201 self.count.store(0, Ordering::SeqCst);
202 }
203}
204
205/// Resource counter for tracking plugin resource usage
206#[derive(Debug)]
207pub struct ResourceCounter {
208 focusables: AtomicU64,
209 overlays: AtomicU64,
210 status_items: AtomicU64,
211}
212
213impl Default for ResourceCounter {
214 fn default() -> Self {
215 Self {
216 focusables: AtomicU64::new(0),
217 overlays: AtomicU64::new(0),
218 status_items: AtomicU64::new(0),
219 }
220 }
221}
222
223impl ResourceCounter {
224 /// Get current focusable count
225 pub fn focusables(&self) -> u64 {
226 self.focusables.load(Ordering::SeqCst)
227 }
228
229 /// Get current overlay count
230 pub fn overlays(&self) -> u64 {
231 self.overlays.load(Ordering::SeqCst)
232 }
233
234 /// Get current status item count
235 pub fn status_items(&self) -> u64 {
236 self.status_items.load(Ordering::SeqCst)
237 }
238
239 /// Increment focusable count, returns new value
240 pub fn add_focusable(&self) -> u64 {
241 self.focusables.fetch_add(1, Ordering::SeqCst) + 1
242 }
243
244 /// Decrement focusable count, returns new value
245 pub fn remove_focusable(&self) -> u64 {
246 self.focusables
247 .fetch_sub(1, Ordering::SeqCst)
248 .saturating_sub(1)
249 }
250
251 /// Increment overlay count
252 pub fn add_overlay(&self) -> u64 {
253 self.overlays.fetch_add(1, Ordering::SeqCst) + 1
254 }
255
256 /// Decrement overlay count
257 pub fn remove_overlay(&self) -> u64 {
258 self.overlays
259 .fetch_sub(1, Ordering::SeqCst)
260 .saturating_sub(1)
261 }
262
263 /// Increment status item count
264 pub fn add_status_item(&self) -> u64 {
265 self.status_items.fetch_add(1, Ordering::SeqCst) + 1
266 }
267
268 /// Decrement status item count
269 pub fn remove_status_item(&self) -> u64 {
270 self.status_items
271 .fetch_sub(1, Ordering::SeqCst)
272 .saturating_sub(1)
273 }
274}
275
276/// ECS-safe host bindings for Fusabi plugins
277///
278/// This struct provides the bridge between Fusabi scripts and Scarab's ECS.
279/// All operations are validated against capability flags, quotas, and rate limits
280/// before being queued as `RemoteCommand`s for the plugin host to process.
281///
282/// # Thread Safety
283///
284/// All methods are thread-safe and can be called from async Fusabi contexts.
285///
286/// # Example
287///
288/// ```ignore
289/// let bindings = HostBindings::new(limits, capabilities);
290///
291/// // Register a focusable (checks quotas and validates)
292/// bindings.register_focusable(&ctx, PluginFocusable {
293/// x: 10, y: 5, width: 20, height: 1,
294/// label: "GitHub".into(),
295/// action: PluginFocusableAction::OpenUrl("https://github.com".into()),
296/// })?;
297///
298/// // Enter hint mode (checks capability and rate limit)
299/// bindings.enter_hint_mode(&ctx)?;
300/// ```
301#[derive(Debug)]
302pub struct HostBindings {
303 /// Configuration limits
304 pub limits: HostBindingLimits,
305 /// Navigation capabilities
306 pub capabilities: PluginNavCapabilities,
307 /// Rate limiter
308 rate_limiter: PluginRateLimiter,
309 /// Resource counters
310 resources: ResourceCounter,
311 /// Next focusable ID
312 next_focusable_id: AtomicU64,
313 /// Next overlay ID
314 next_overlay_id: AtomicU64,
315 /// Next status item ID
316 next_status_item_id: AtomicU64,
317 /// Selected nav style
318 nav_style: Mutex<NavStyle>,
319 /// Selected nav keymap
320 nav_keymap: Mutex<NavKeymap>,
321}
322
323impl HostBindings {
324 /// Create new host bindings with the specified limits and capabilities
325 pub fn new(limits: HostBindingLimits, capabilities: PluginNavCapabilities) -> Self {
326 Self {
327 rate_limiter: PluginRateLimiter::new(limits.rate_limit),
328 limits,
329 capabilities,
330 resources: ResourceCounter::default(),
331 next_focusable_id: AtomicU64::new(1),
332 next_overlay_id: AtomicU64::new(1),
333 next_status_item_id: AtomicU64::new(1),
334 nav_style: Mutex::new(NavStyle::default()),
335 nav_keymap: Mutex::new(NavKeymap::default()),
336 }
337 }
338
339 /// Create with default limits and capabilities
340 pub fn with_defaults() -> Self {
341 Self::new(
342 HostBindingLimits::default(),
343 PluginNavCapabilities::default(),
344 )
345 }
346
347 /// Check rate limit before action
348 fn check_rate_limit(&self) -> Result<()> {
349 self.rate_limiter.check()
350 }
351
352 /// Enter hint mode
353 ///
354 /// Triggers the navigation hint mode UI, displaying labels for all
355 /// focusable elements.
356 ///
357 /// # Errors
358 ///
359 /// Returns error if:
360 /// - Plugin doesn't have `can_enter_hint_mode` capability
361 /// - Rate limit exceeded
362 pub fn enter_hint_mode(&self, ctx: &PluginContext) -> Result<()> {
363 if !self.capabilities.can_enter_hint_mode {
364 return Err(PluginError::CapabilityDenied("enter_hint_mode".into()));
365 }
366
367 self.check_rate_limit()?;
368
369 ctx.queue_command(crate::types::RemoteCommand::NavEnterHintMode {
370 plugin_name: ctx.logger_name.clone(),
371 });
372
373 Ok(())
374 }
375
376 /// Exit navigation mode
377 ///
378 /// Exits hint mode and returns to normal input handling.
379 ///
380 /// # Errors
381 ///
382 /// Returns error if rate limit exceeded
383 pub fn exit_nav_mode(&self, ctx: &PluginContext) -> Result<()> {
384 self.check_rate_limit()?;
385
386 ctx.queue_command(crate::types::RemoteCommand::NavExitMode {
387 plugin_name: ctx.logger_name.clone(),
388 });
389
390 Ok(())
391 }
392
393 /// Register a focusable region
394 ///
395 /// Registers a custom navigation target that will appear in hint mode.
396 /// The region is validated for bounds and the action is checked for safety.
397 ///
398 /// # Arguments
399 ///
400 /// * `ctx` - Plugin context
401 /// * `region` - Focusable region to register
402 ///
403 /// # Returns
404 ///
405 /// Unique ID for this focusable (can be used to unregister)
406 ///
407 /// # Errors
408 ///
409 /// Returns error if:
410 /// - Plugin doesn't have `can_register_focusables` capability
411 /// - Plugin has reached `max_focusables` quota
412 /// - Region fails validation (coordinates, dimensions, URL safety)
413 /// - Rate limit exceeded
414 pub fn register_focusable(&self, ctx: &PluginContext, region: PluginFocusable) -> Result<u64> {
415 if !self.capabilities.can_register_focusables {
416 return Err(PluginError::CapabilityDenied("register_focusables".into()));
417 }
418
419 // Check quota
420 let current = self.resources.focusables();
421 if current >= self.capabilities.max_focusables as u64 {
422 return Err(PluginError::QuotaExceeded {
423 resource: "focusables".into(),
424 current: current as usize,
425 limit: self.capabilities.max_focusables,
426 });
427 }
428
429 // Validate region
430 if self.limits.bounds_check {
431 validate_focusable(®ion).map_err(|e| PluginError::ValidationError(e.to_string()))?;
432 }
433
434 self.check_rate_limit()?;
435
436 let focusable_id = self.next_focusable_id.fetch_add(1, Ordering::SeqCst);
437 self.resources.add_focusable();
438
439 // Convert action to protocol format
440 let action = match ®ion.action {
441 PluginFocusableAction::OpenUrl(url) => {
442 scarab_protocol::NavFocusableAction::OpenUrl(url.clone().into())
443 }
444 PluginFocusableAction::OpenFile(path) => {
445 scarab_protocol::NavFocusableAction::OpenFile(path.clone().into())
446 }
447 PluginFocusableAction::Custom(name) => {
448 scarab_protocol::NavFocusableAction::Custom(name.clone().into())
449 }
450 };
451
452 ctx.queue_command(crate::types::RemoteCommand::NavRegisterFocusable {
453 plugin_name: ctx.logger_name.clone(),
454 x: region.x,
455 y: region.y,
456 width: region.width,
457 height: region.height,
458 label: region.label.clone(),
459 action,
460 });
461
462 Ok(focusable_id)
463 }
464
465 /// Unregister a focusable region
466 ///
467 /// Removes a previously registered focusable from the navigation system.
468 ///
469 /// # Arguments
470 ///
471 /// * `ctx` - Plugin context
472 /// * `focusable_id` - ID returned from `register_focusable`
473 ///
474 /// # Errors
475 ///
476 /// Returns error if rate limit exceeded
477 pub fn unregister_focusable(&self, ctx: &PluginContext, focusable_id: u64) -> Result<()> {
478 self.check_rate_limit()?;
479
480 self.resources.remove_focusable();
481
482 ctx.queue_command(crate::types::RemoteCommand::NavUnregisterFocusable {
483 plugin_name: ctx.logger_name.clone(),
484 focusable_id,
485 });
486
487 Ok(())
488 }
489
490 /// Set navigation style
491 ///
492 /// Configures the visual style of navigation hints (character set, appearance).
493 pub fn set_nav_style(&self, style: NavStyle) {
494 *self.nav_style.lock() = style;
495 }
496
497 /// Get current navigation style
498 pub fn nav_style(&self) -> NavStyle {
499 self.nav_style.lock().clone()
500 }
501
502 /// Set navigation keymap
503 ///
504 /// Configures the keybindings for navigation mode.
505 pub fn set_nav_keymap(&self, keymap: NavKeymap) {
506 *self.nav_keymap.lock() = keymap;
507 }
508
509 /// Get current navigation keymap
510 pub fn nav_keymap(&self) -> NavKeymap {
511 self.nav_keymap.lock().clone()
512 }
513
514 /// Get current resource usage
515 pub fn resource_usage(&self) -> ResourceUsage {
516 ResourceUsage {
517 focusables: self.resources.focusables() as usize,
518 overlays: self.resources.overlays() as usize,
519 status_items: self.resources.status_items() as usize,
520 max_focusables: self.capabilities.max_focusables,
521 max_overlays: self.limits.max_overlays,
522 max_status_items: self.limits.max_status_items,
523 }
524 }
525
526 /// Reset rate limiter (useful for testing)
527 pub fn reset_rate_limit(&self) {
528 self.rate_limiter.reset();
529 }
530
531 // ========================================================================
532 // New ECS-safe UI/Nav Bindings (Fusabi 0.21.0)
533 // ========================================================================
534
535 /// Spawn an overlay at the given position
536 ///
537 /// Creates a floating overlay element at the specified terminal coordinates.
538 /// Overlays are useful for tooltips, popups, and other transient UI elements.
539 ///
540 /// # Arguments
541 ///
542 /// * `ctx` - Plugin context
543 /// * `config` - Overlay configuration (position, content, style)
544 ///
545 /// # Returns
546 ///
547 /// Unique ID for this overlay (can be used to remove it later)
548 ///
549 /// # Errors
550 ///
551 /// Returns error if:
552 /// - Plugin has reached `max_overlays` quota
553 /// - Rate limit exceeded
554 /// - Overlay position is out of bounds
555 pub fn spawn_overlay(&self, ctx: &PluginContext, config: OverlayConfig) -> Result<u64> {
556 let current = self.resources.overlays();
557 if current >= self.limits.max_overlays as u64 {
558 return Err(PluginError::QuotaExceeded {
559 resource: "overlays".into(),
560 current: current as usize,
561 limit: self.limits.max_overlays,
562 });
563 }
564
565 if self.limits.bounds_check
566 && (config.x >= self.limits.max_coordinate || config.y >= self.limits.max_coordinate)
567 {
568 return Err(PluginError::ValidationError(format!(
569 "Overlay position ({}, {}) exceeds max coordinate {}",
570 config.x, config.y, self.limits.max_coordinate
571 )));
572 }
573
574 self.check_rate_limit()?;
575
576 let overlay_id = self.next_overlay_id.fetch_add(1, Ordering::SeqCst);
577 self.resources.add_overlay();
578
579 ctx.queue_command(crate::types::RemoteCommand::SpawnOverlay {
580 plugin_name: ctx.logger_name.clone(),
581 overlay_id,
582 config,
583 });
584
585 Ok(overlay_id)
586 }
587
588 /// Remove a previously spawned overlay
589 ///
590 /// Removes an overlay by its ID. If the overlay doesn't exist, this is a no-op.
591 ///
592 /// # Arguments
593 ///
594 /// * `ctx` - Plugin context
595 /// * `overlay_id` - ID returned from `spawn_overlay`
596 ///
597 /// # Errors
598 ///
599 /// Returns error if rate limit exceeded
600 pub fn remove_overlay(&self, ctx: &PluginContext, overlay_id: u64) -> Result<()> {
601 self.check_rate_limit()?;
602
603 self.resources.remove_overlay();
604
605 ctx.queue_command(crate::types::RemoteCommand::RemoveOverlay {
606 plugin_name: ctx.logger_name.clone(),
607 overlay_id,
608 });
609
610 Ok(())
611 }
612
613 /// Add a status bar item
614 ///
615 /// Adds an item to the terminal status bar. Status items are positioned
616 /// based on their priority (higher priority = further right).
617 ///
618 /// # Arguments
619 ///
620 /// * `ctx` - Plugin context
621 /// * `item` - Status bar item configuration
622 ///
623 /// # Returns
624 ///
625 /// Unique ID for this status item (can be used to remove it later)
626 ///
627 /// # Errors
628 ///
629 /// Returns error if:
630 /// - Plugin has reached `max_status_items` quota
631 /// - Rate limit exceeded
632 pub fn add_status_item(&self, ctx: &PluginContext, item: StatusBarItem) -> Result<u64> {
633 let current = self.resources.status_items();
634 if current >= self.limits.max_status_items as u64 {
635 return Err(PluginError::QuotaExceeded {
636 resource: "status_items".into(),
637 current: current as usize,
638 limit: self.limits.max_status_items,
639 });
640 }
641
642 self.check_rate_limit()?;
643
644 let item_id = self.next_status_item_id.fetch_add(1, Ordering::SeqCst);
645 self.resources.add_status_item();
646
647 ctx.queue_command(crate::types::RemoteCommand::AddStatusItem {
648 plugin_name: ctx.logger_name.clone(),
649 item_id,
650 item,
651 });
652
653 Ok(item_id)
654 }
655
656 /// Remove a status bar item
657 ///
658 /// Removes a status bar item by its ID. If the item doesn't exist, this is a no-op.
659 ///
660 /// # Arguments
661 ///
662 /// * `ctx` - Plugin context
663 /// * `item_id` - ID returned from `add_status_item`
664 ///
665 /// # Errors
666 ///
667 /// Returns error if rate limit exceeded
668 pub fn remove_status_item(&self, ctx: &PluginContext, item_id: u64) -> Result<()> {
669 self.check_rate_limit()?;
670
671 self.resources.remove_status_item();
672
673 ctx.queue_command(crate::types::RemoteCommand::RemoveStatusItem {
674 plugin_name: ctx.logger_name.clone(),
675 item_id,
676 });
677
678 Ok(())
679 }
680
681 /// Trigger prompt jump navigation
682 ///
683 /// Navigates the terminal viewport to the previous/next command prompt
684 /// in the scrollback buffer. Useful for quickly navigating command history.
685 ///
686 /// # Arguments
687 ///
688 /// * `ctx` - Plugin context
689 /// * `direction` - Direction to jump (Up, Down, First, Last)
690 ///
691 /// # Errors
692 ///
693 /// Returns error if rate limit exceeded
694 pub fn prompt_jump(&self, ctx: &PluginContext, direction: JumpDirection) -> Result<()> {
695 self.check_rate_limit()?;
696
697 ctx.queue_command(crate::types::RemoteCommand::PromptJump {
698 plugin_name: ctx.logger_name.clone(),
699 direction,
700 });
701
702 Ok(())
703 }
704
705 // ========================================================================
706 // Theme Manipulation Bindings
707 // ========================================================================
708
709 /// Apply a named theme
710 ///
711 /// Dynamically applies a color theme to the terminal. The theme must be
712 /// one of the built-in themes or a custom theme registered with the
713 /// configuration system.
714 ///
715 /// # Built-in Themes
716 ///
717 /// - `slime` - Vibrant green tones (default)
718 /// - `dracula` - Dark purple theme
719 /// - `nord` - Arctic, north-bluish color palette
720 /// - `monokai` - Classic dark theme with warm accents
721 /// - `gruvbox_dark` - Retro groove color scheme
722 /// - `solarized_dark` - Precision colors for machines
723 /// - `solarized_light` - Light variant of solarized
724 /// - `tokyo_night` - A clean, dark theme from Tokyo
725 /// - `catppuccin` - Soothing pastel theme
726 ///
727 /// # Arguments
728 ///
729 /// * `ctx` - Plugin context
730 /// * `theme_name` - Name of the theme to apply
731 ///
732 /// # Errors
733 ///
734 /// Returns error if rate limit exceeded
735 ///
736 /// # Example
737 ///
738 /// ```fsharp
739 /// Host.applyTheme ctx "dracula"
740 /// ```
741 pub fn apply_theme(&self, ctx: &PluginContext, theme_name: &str) -> Result<()> {
742 self.check_rate_limit()?;
743
744 ctx.queue_command(crate::types::RemoteCommand::ApplyTheme {
745 plugin_name: ctx.logger_name.clone(),
746 theme_name: theme_name.to_string(),
747 });
748
749 Ok(())
750 }
751
752 /// Set a specific palette color
753 ///
754 /// Modifies a single color in the current palette. This allows fine-grained
755 /// customization of terminal colors without changing the entire theme.
756 ///
757 /// # Color Names
758 ///
759 /// - `foreground`, `background`
760 /// - `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`
761 /// - `bright_black`, `bright_red`, `bright_green`, `bright_yellow`
762 /// - `bright_blue`, `bright_magenta`, `bright_cyan`, `bright_white`
763 /// - `cursor`, `selection`
764 ///
765 /// # Color Values
766 ///
767 /// Colors can be specified as:
768 /// - Hex: `#RRGGBB` or `RRGGBB`
769 /// - RGB: `rgb(255, 0, 128)`
770 /// - Named: `red`, `green`, `blue`, etc.
771 ///
772 /// # Arguments
773 ///
774 /// * `ctx` - Plugin context
775 /// * `color_name` - Name of the color to set
776 /// * `value` - New color value (hex, rgb, or named)
777 ///
778 /// # Errors
779 ///
780 /// Returns error if rate limit exceeded
781 ///
782 /// # Example
783 ///
784 /// ```fsharp
785 /// Host.setPaletteColor ctx "foreground" "#00FF00"
786 /// Host.setPaletteColor ctx "background" "rgb(30, 30, 30)"
787 /// ```
788 pub fn set_palette_color(
789 &self,
790 ctx: &PluginContext,
791 color_name: &str,
792 value: &str,
793 ) -> Result<()> {
794 self.check_rate_limit()?;
795
796 ctx.queue_command(crate::types::RemoteCommand::SetPaletteColor {
797 plugin_name: ctx.logger_name.clone(),
798 color_name: color_name.to_string(),
799 value: value.to_string(),
800 });
801
802 Ok(())
803 }
804
805 /// Request the current theme name
806 ///
807 /// Queries the current active theme name. Since this is an async operation,
808 /// the result will be delivered via a callback or event.
809 ///
810 /// # Arguments
811 ///
812 /// * `ctx` - Plugin context
813 ///
814 /// # Errors
815 ///
816 /// Returns error if rate limit exceeded
817 ///
818 /// # Note
819 ///
820 /// The theme name is returned asynchronously. Plugins should listen for
821 /// the `ThemeInfoResponse` event to receive the result.
822 pub fn get_current_theme(&self, ctx: &PluginContext) -> Result<()> {
823 self.check_rate_limit()?;
824
825 ctx.queue_command(crate::types::RemoteCommand::GetCurrentTheme {
826 plugin_name: ctx.logger_name.clone(),
827 });
828
829 Ok(())
830 }
831}
832
833/// Current resource usage snapshot
834#[derive(Debug, Clone)]
835pub struct ResourceUsage {
836 /// Current focusable count
837 pub focusables: usize,
838 /// Current overlay count
839 pub overlays: usize,
840 /// Current status item count
841 pub status_items: usize,
842 /// Maximum focusables allowed
843 pub max_focusables: usize,
844 /// Maximum overlays allowed
845 pub max_overlays: usize,
846 /// Maximum status items allowed
847 pub max_status_items: usize,
848}
849
850impl ResourceUsage {
851 /// Check if any resource is at its limit
852 pub fn any_at_limit(&self) -> bool {
853 self.focusables >= self.max_focusables
854 || self.overlays >= self.max_overlays
855 || self.status_items >= self.max_status_items
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use crate::context::{PluginConfigData, PluginSharedState};
863 use std::sync::Arc;
864
865 fn make_test_ctx() -> PluginContext {
866 PluginContext::new(
867 PluginConfigData::default(),
868 Arc::new(Mutex::new(PluginSharedState::new(80, 24))),
869 "test_plugin",
870 )
871 }
872
873 #[test]
874 fn test_host_bindings_creation() {
875 let bindings = HostBindings::with_defaults();
876 assert_eq!(bindings.limits.max_focusables, DEFAULT_MAX_FOCUSABLES);
877 assert_eq!(bindings.limits.rate_limit, DEFAULT_RATE_LIMIT);
878 }
879
880 #[test]
881 fn test_rate_limiter() {
882 let limiter = PluginRateLimiter::new(3);
883
884 assert!(limiter.check().is_ok());
885 assert!(limiter.check().is_ok());
886 assert!(limiter.check().is_ok());
887 assert!(limiter.check().is_err());
888
889 limiter.reset();
890 assert!(limiter.check().is_ok());
891 }
892
893 #[test]
894 fn test_resource_counter() {
895 let counter = ResourceCounter::default();
896
897 assert_eq!(counter.focusables(), 0);
898 assert_eq!(counter.add_focusable(), 1);
899 assert_eq!(counter.add_focusable(), 2);
900 assert_eq!(counter.focusables(), 2);
901 assert_eq!(counter.remove_focusable(), 1);
902 assert_eq!(counter.focusables(), 1);
903 }
904
905 #[test]
906 fn test_nav_style_hint_chars() {
907 assert_eq!(NavStyle::Vimium.hint_chars(), "sadfjklewcmpgh");
908 assert_eq!(NavStyle::Numeric.hint_chars(), "1234567890");
909 assert_eq!(NavStyle::Custom("abc".into()).hint_chars(), "abc");
910 }
911
912 #[test]
913 fn test_capability_denied() {
914 let ctx = make_test_ctx();
915 let caps = PluginNavCapabilities {
916 can_enter_hint_mode: false,
917 ..Default::default()
918 };
919 let bindings = HostBindings::new(HostBindingLimits::default(), caps);
920
921 let result = bindings.enter_hint_mode(&ctx);
922 assert!(matches!(result, Err(PluginError::CapabilityDenied(_))));
923 }
924
925 #[test]
926 fn test_quota_exceeded() {
927 let ctx = make_test_ctx();
928 let caps = PluginNavCapabilities {
929 max_focusables: 1,
930 ..Default::default()
931 };
932 let bindings = HostBindings::new(HostBindingLimits::default(), caps);
933
934 // First should succeed
935 let region = PluginFocusable {
936 x: 0,
937 y: 0,
938 width: 10,
939 height: 1,
940 label: "Test".into(),
941 action: PluginFocusableAction::OpenUrl("https://example.com".into()),
942 };
943 assert!(bindings.register_focusable(&ctx, region.clone()).is_ok());
944
945 // Second should fail quota
946 let result = bindings.register_focusable(&ctx, region);
947 assert!(matches!(result, Err(PluginError::QuotaExceeded { .. })));
948 }
949
950 #[test]
951 fn test_resource_usage() {
952 let bindings = HostBindings::with_defaults();
953 let usage = bindings.resource_usage();
954
955 assert_eq!(usage.focusables, 0);
956 assert!(!usage.any_at_limit());
957 }
958}