Skip to main content

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(&region).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 &region.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}