use crate::context::PluginContext;
use crate::error::{PluginError, Result};
use crate::navigation::{
validate_focusable, PluginFocusable, PluginFocusableAction, PluginNavCapabilities,
};
use crate::types::{JumpDirection, OverlayConfig, StatusBarItem};
use parking_lot::Mutex;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
pub const DEFAULT_RATE_LIMIT: u32 = 10;
pub const DEFAULT_MAX_FOCUSABLES: usize = 50;
pub const DEFAULT_MAX_OVERLAYS: usize = 10;
pub const DEFAULT_MAX_STATUS_ITEMS: usize = 5;
#[derive(Debug, Clone)]
pub struct HostBindingLimits {
pub max_focusables: usize,
pub max_overlays: usize,
pub max_status_items: usize,
pub rate_limit: u32,
pub bounds_check: bool,
pub max_coordinate: u16,
}
impl Default for HostBindingLimits {
fn default() -> Self {
Self {
max_focusables: DEFAULT_MAX_FOCUSABLES,
max_overlays: DEFAULT_MAX_OVERLAYS,
max_status_items: DEFAULT_MAX_STATUS_ITEMS,
rate_limit: DEFAULT_RATE_LIMIT,
bounds_check: true,
max_coordinate: 1000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum NavStyle {
#[default]
Vimium,
VimiumUppercase,
Numeric,
HomeRow,
Custom(String),
}
impl NavStyle {
pub fn hint_chars(&self) -> &str {
match self {
NavStyle::Vimium => "sadfjklewcmpgh",
NavStyle::VimiumUppercase => "SADFJKLEWCMPGH",
NavStyle::Numeric => "1234567890",
NavStyle::HomeRow => "asdfghjkl",
NavStyle::Custom(chars) => chars,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum NavKeymap {
#[default]
Default,
Vim,
Emacs,
Custom(Vec<(String, String)>),
}
#[derive(Debug)]
pub struct PluginRateLimiter {
limit: u32,
count: AtomicU32,
window_start: Mutex<Instant>,
}
impl PluginRateLimiter {
pub fn new(limit: u32) -> Self {
Self {
limit,
count: AtomicU32::new(0),
window_start: Mutex::new(Instant::now()),
}
}
pub fn check(&self) -> Result<()> {
let now = Instant::now();
{
let mut window = self.window_start.lock();
if now.duration_since(*window).as_secs() >= 1 {
*window = now;
self.count.store(0, Ordering::SeqCst);
}
}
let current = self.count.fetch_add(1, Ordering::SeqCst);
if current >= self.limit {
self.count.fetch_sub(1, Ordering::SeqCst); return Err(PluginError::RateLimitExceeded {
current: current + 1,
limit: self.limit,
});
}
Ok(())
}
pub fn reset(&self) {
*self.window_start.lock() = Instant::now();
self.count.store(0, Ordering::SeqCst);
}
}
#[derive(Debug)]
pub struct ResourceCounter {
focusables: AtomicU64,
overlays: AtomicU64,
status_items: AtomicU64,
}
impl Default for ResourceCounter {
fn default() -> Self {
Self {
focusables: AtomicU64::new(0),
overlays: AtomicU64::new(0),
status_items: AtomicU64::new(0),
}
}
}
impl ResourceCounter {
pub fn focusables(&self) -> u64 {
self.focusables.load(Ordering::SeqCst)
}
pub fn overlays(&self) -> u64 {
self.overlays.load(Ordering::SeqCst)
}
pub fn status_items(&self) -> u64 {
self.status_items.load(Ordering::SeqCst)
}
pub fn add_focusable(&self) -> u64 {
self.focusables.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn remove_focusable(&self) -> u64 {
self.focusables
.fetch_sub(1, Ordering::SeqCst)
.saturating_sub(1)
}
pub fn add_overlay(&self) -> u64 {
self.overlays.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn remove_overlay(&self) -> u64 {
self.overlays
.fetch_sub(1, Ordering::SeqCst)
.saturating_sub(1)
}
pub fn add_status_item(&self) -> u64 {
self.status_items.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn remove_status_item(&self) -> u64 {
self.status_items
.fetch_sub(1, Ordering::SeqCst)
.saturating_sub(1)
}
}
#[derive(Debug)]
pub struct HostBindings {
pub limits: HostBindingLimits,
pub capabilities: PluginNavCapabilities,
rate_limiter: PluginRateLimiter,
resources: ResourceCounter,
next_focusable_id: AtomicU64,
next_overlay_id: AtomicU64,
next_status_item_id: AtomicU64,
nav_style: Mutex<NavStyle>,
nav_keymap: Mutex<NavKeymap>,
}
impl HostBindings {
pub fn new(limits: HostBindingLimits, capabilities: PluginNavCapabilities) -> Self {
Self {
rate_limiter: PluginRateLimiter::new(limits.rate_limit),
limits,
capabilities,
resources: ResourceCounter::default(),
next_focusable_id: AtomicU64::new(1),
next_overlay_id: AtomicU64::new(1),
next_status_item_id: AtomicU64::new(1),
nav_style: Mutex::new(NavStyle::default()),
nav_keymap: Mutex::new(NavKeymap::default()),
}
}
pub fn with_defaults() -> Self {
Self::new(
HostBindingLimits::default(),
PluginNavCapabilities::default(),
)
}
fn check_rate_limit(&self) -> Result<()> {
self.rate_limiter.check()
}
pub fn enter_hint_mode(&self, ctx: &PluginContext) -> Result<()> {
if !self.capabilities.can_enter_hint_mode {
return Err(PluginError::CapabilityDenied("enter_hint_mode".into()));
}
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::NavEnterHintMode {
plugin_name: ctx.logger_name.clone(),
});
Ok(())
}
pub fn exit_nav_mode(&self, ctx: &PluginContext) -> Result<()> {
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::NavExitMode {
plugin_name: ctx.logger_name.clone(),
});
Ok(())
}
pub fn register_focusable(&self, ctx: &PluginContext, region: PluginFocusable) -> Result<u64> {
if !self.capabilities.can_register_focusables {
return Err(PluginError::CapabilityDenied("register_focusables".into()));
}
let current = self.resources.focusables();
if current >= self.capabilities.max_focusables as u64 {
return Err(PluginError::QuotaExceeded {
resource: "focusables".into(),
current: current as usize,
limit: self.capabilities.max_focusables,
});
}
if self.limits.bounds_check {
validate_focusable(®ion).map_err(|e| PluginError::ValidationError(e.to_string()))?;
}
self.check_rate_limit()?;
let focusable_id = self.next_focusable_id.fetch_add(1, Ordering::SeqCst);
self.resources.add_focusable();
let action = match ®ion.action {
PluginFocusableAction::OpenUrl(url) => {
scarab_protocol::NavFocusableAction::OpenUrl(url.clone().into())
}
PluginFocusableAction::OpenFile(path) => {
scarab_protocol::NavFocusableAction::OpenFile(path.clone().into())
}
PluginFocusableAction::Custom(name) => {
scarab_protocol::NavFocusableAction::Custom(name.clone().into())
}
};
ctx.queue_command(crate::types::RemoteCommand::NavRegisterFocusable {
plugin_name: ctx.logger_name.clone(),
x: region.x,
y: region.y,
width: region.width,
height: region.height,
label: region.label.clone(),
action,
});
Ok(focusable_id)
}
pub fn unregister_focusable(&self, ctx: &PluginContext, focusable_id: u64) -> Result<()> {
self.check_rate_limit()?;
self.resources.remove_focusable();
ctx.queue_command(crate::types::RemoteCommand::NavUnregisterFocusable {
plugin_name: ctx.logger_name.clone(),
focusable_id,
});
Ok(())
}
pub fn set_nav_style(&self, style: NavStyle) {
*self.nav_style.lock() = style;
}
pub fn nav_style(&self) -> NavStyle {
self.nav_style.lock().clone()
}
pub fn set_nav_keymap(&self, keymap: NavKeymap) {
*self.nav_keymap.lock() = keymap;
}
pub fn nav_keymap(&self) -> NavKeymap {
self.nav_keymap.lock().clone()
}
pub fn resource_usage(&self) -> ResourceUsage {
ResourceUsage {
focusables: self.resources.focusables() as usize,
overlays: self.resources.overlays() as usize,
status_items: self.resources.status_items() as usize,
max_focusables: self.capabilities.max_focusables,
max_overlays: self.limits.max_overlays,
max_status_items: self.limits.max_status_items,
}
}
pub fn reset_rate_limit(&self) {
self.rate_limiter.reset();
}
pub fn spawn_overlay(&self, ctx: &PluginContext, config: OverlayConfig) -> Result<u64> {
let current = self.resources.overlays();
if current >= self.limits.max_overlays as u64 {
return Err(PluginError::QuotaExceeded {
resource: "overlays".into(),
current: current as usize,
limit: self.limits.max_overlays,
});
}
if self.limits.bounds_check
&& (config.x >= self.limits.max_coordinate || config.y >= self.limits.max_coordinate)
{
return Err(PluginError::ValidationError(format!(
"Overlay position ({}, {}) exceeds max coordinate {}",
config.x, config.y, self.limits.max_coordinate
)));
}
self.check_rate_limit()?;
let overlay_id = self.next_overlay_id.fetch_add(1, Ordering::SeqCst);
self.resources.add_overlay();
ctx.queue_command(crate::types::RemoteCommand::SpawnOverlay {
plugin_name: ctx.logger_name.clone(),
overlay_id,
config,
});
Ok(overlay_id)
}
pub fn remove_overlay(&self, ctx: &PluginContext, overlay_id: u64) -> Result<()> {
self.check_rate_limit()?;
self.resources.remove_overlay();
ctx.queue_command(crate::types::RemoteCommand::RemoveOverlay {
plugin_name: ctx.logger_name.clone(),
overlay_id,
});
Ok(())
}
pub fn add_status_item(&self, ctx: &PluginContext, item: StatusBarItem) -> Result<u64> {
let current = self.resources.status_items();
if current >= self.limits.max_status_items as u64 {
return Err(PluginError::QuotaExceeded {
resource: "status_items".into(),
current: current as usize,
limit: self.limits.max_status_items,
});
}
self.check_rate_limit()?;
let item_id = self.next_status_item_id.fetch_add(1, Ordering::SeqCst);
self.resources.add_status_item();
ctx.queue_command(crate::types::RemoteCommand::AddStatusItem {
plugin_name: ctx.logger_name.clone(),
item_id,
item,
});
Ok(item_id)
}
pub fn remove_status_item(&self, ctx: &PluginContext, item_id: u64) -> Result<()> {
self.check_rate_limit()?;
self.resources.remove_status_item();
ctx.queue_command(crate::types::RemoteCommand::RemoveStatusItem {
plugin_name: ctx.logger_name.clone(),
item_id,
});
Ok(())
}
pub fn prompt_jump(&self, ctx: &PluginContext, direction: JumpDirection) -> Result<()> {
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::PromptJump {
plugin_name: ctx.logger_name.clone(),
direction,
});
Ok(())
}
pub fn apply_theme(&self, ctx: &PluginContext, theme_name: &str) -> Result<()> {
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::ApplyTheme {
plugin_name: ctx.logger_name.clone(),
theme_name: theme_name.to_string(),
});
Ok(())
}
pub fn set_palette_color(
&self,
ctx: &PluginContext,
color_name: &str,
value: &str,
) -> Result<()> {
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::SetPaletteColor {
plugin_name: ctx.logger_name.clone(),
color_name: color_name.to_string(),
value: value.to_string(),
});
Ok(())
}
pub fn get_current_theme(&self, ctx: &PluginContext) -> Result<()> {
self.check_rate_limit()?;
ctx.queue_command(crate::types::RemoteCommand::GetCurrentTheme {
plugin_name: ctx.logger_name.clone(),
});
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ResourceUsage {
pub focusables: usize,
pub overlays: usize,
pub status_items: usize,
pub max_focusables: usize,
pub max_overlays: usize,
pub max_status_items: usize,
}
impl ResourceUsage {
pub fn any_at_limit(&self) -> bool {
self.focusables >= self.max_focusables
|| self.overlays >= self.max_overlays
|| self.status_items >= self.max_status_items
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{PluginConfigData, PluginSharedState};
use std::sync::Arc;
fn make_test_ctx() -> PluginContext {
PluginContext::new(
PluginConfigData::default(),
Arc::new(Mutex::new(PluginSharedState::new(80, 24))),
"test_plugin",
)
}
#[test]
fn test_host_bindings_creation() {
let bindings = HostBindings::with_defaults();
assert_eq!(bindings.limits.max_focusables, DEFAULT_MAX_FOCUSABLES);
assert_eq!(bindings.limits.rate_limit, DEFAULT_RATE_LIMIT);
}
#[test]
fn test_rate_limiter() {
let limiter = PluginRateLimiter::new(3);
assert!(limiter.check().is_ok());
assert!(limiter.check().is_ok());
assert!(limiter.check().is_ok());
assert!(limiter.check().is_err());
limiter.reset();
assert!(limiter.check().is_ok());
}
#[test]
fn test_resource_counter() {
let counter = ResourceCounter::default();
assert_eq!(counter.focusables(), 0);
assert_eq!(counter.add_focusable(), 1);
assert_eq!(counter.add_focusable(), 2);
assert_eq!(counter.focusables(), 2);
assert_eq!(counter.remove_focusable(), 1);
assert_eq!(counter.focusables(), 1);
}
#[test]
fn test_nav_style_hint_chars() {
assert_eq!(NavStyle::Vimium.hint_chars(), "sadfjklewcmpgh");
assert_eq!(NavStyle::Numeric.hint_chars(), "1234567890");
assert_eq!(NavStyle::Custom("abc".into()).hint_chars(), "abc");
}
#[test]
fn test_capability_denied() {
let ctx = make_test_ctx();
let caps = PluginNavCapabilities {
can_enter_hint_mode: false,
..Default::default()
};
let bindings = HostBindings::new(HostBindingLimits::default(), caps);
let result = bindings.enter_hint_mode(&ctx);
assert!(matches!(result, Err(PluginError::CapabilityDenied(_))));
}
#[test]
fn test_quota_exceeded() {
let ctx = make_test_ctx();
let caps = PluginNavCapabilities {
max_focusables: 1,
..Default::default()
};
let bindings = HostBindings::new(HostBindingLimits::default(), caps);
let region = PluginFocusable {
x: 0,
y: 0,
width: 10,
height: 1,
label: "Test".into(),
action: PluginFocusableAction::OpenUrl("https://example.com".into()),
};
assert!(bindings.register_focusable(&ctx, region.clone()).is_ok());
let result = bindings.register_focusable(&ctx, region);
assert!(matches!(result, Err(PluginError::QuotaExceeded { .. })));
}
#[test]
fn test_resource_usage() {
let bindings = HostBindings::with_defaults();
let usage = bindings.resource_usage();
assert_eq!(usage.focusables, 0);
assert!(!usage.any_at_limit());
}
}