use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{LazyLock, RwLock};
use crate::{Context, SharedString, Window};
use super::state::EditorState;
#[derive(Debug, Clone, Copy)]
pub struct EditEvent {
pub cursor: usize,
pub text_len: usize,
}
pub trait EditorExtension {
fn name(&self) -> SharedString;
fn on_edit(&mut self, _event: &EditEvent) {}
}
pub type EditorExtensionFactory = Box<dyn Fn() -> Box<dyn EditorExtension> + Send + Sync>;
pub type EditHandler = Rc<dyn Fn(&EditEvent, &mut Window, &mut Context<EditorState>)>;
static EXTENSION_REGISTRY: LazyLock<RwLock<HashMap<&'static str, EditorExtensionFactory>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
pub fn register_editor_extension(name: &'static str, factory: EditorExtensionFactory) {
if let Ok(mut registry) = EXTENSION_REGISTRY.write() {
registry.insert(name, factory);
}
}
pub fn editor_extension(name: &str) -> Option<Box<dyn EditorExtension>> {
EXTENSION_REGISTRY
.read()
.ok()?
.get(name)
.map(|factory| factory())
}
pub(super) struct ExtensionsState {
attached: Vec<Box<dyn EditorExtension>>,
handlers: Vec<(u64, EditHandler)>,
next_id: u64,
}
impl ExtensionsState {
pub(super) fn new() -> Self {
Self {
attached: Vec::new(),
handlers: Vec::new(),
next_id: 0,
}
}
}
impl EditorState {
pub fn attach_extension(&mut self, name: &str, cx: &mut Context<Self>) -> bool {
if self
.extensions
.attached
.iter()
.any(|ext| ext.name() == name)
{
return false;
}
let Some(extension) = editor_extension(name) else {
return false;
};
self.extensions.attached.push(extension);
cx.notify();
true
}
pub fn attached_extensions(&self) -> Vec<SharedString> {
self.extensions
.attached
.iter()
.map(|ext| ext.name())
.collect()
}
pub fn on_edit(
&mut self,
handler: impl Fn(&EditEvent, &mut Window, &mut Context<Self>) + 'static,
) -> u64 {
let id = self.extensions.next_id;
self.extensions.next_id = self.extensions.next_id.wrapping_add(1);
self.extensions.handlers.push((id, Rc::new(handler)));
id
}
pub fn remove_on_edit(&mut self, id: u64) -> bool {
let before = self.extensions.handlers.len();
self.extensions.handlers.retain(|(known, _)| *known != id);
self.extensions.handlers.len() != before
}
pub(super) fn fire_edit_event(
&mut self,
event: &EditEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
for extension in &mut self.extensions.attached {
extension.on_edit(event);
}
let handlers: Vec<EditHandler> = self
.extensions
.handlers
.iter()
.map(|(_, hook)| hook.clone())
.collect();
for handler in handlers {
handler(event, window, cx);
}
}
pub(super) fn edit_event<C: crate::AppContext>(&self, cx: &C) -> EditEvent {
self.input.read_with(cx, |state, _| EditEvent {
cursor: state.cursor(),
text_len: state.text().len(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::Render;
use std::sync::{Arc, Mutex};
struct Probe {
state: crate::Entity<EditorState>,
}
impl Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
struct CountingExtension {
events: Arc<Mutex<Vec<EditEvent>>>,
}
impl EditorExtension for CountingExtension {
fn name(&self) -> SharedString {
"test-only-counting".into()
}
fn on_edit(&mut self, event: &EditEvent) {
self.events.lock().unwrap().push(*event);
}
}
fn type_text(
editor: &crate::Entity<EditorState>,
text: &str,
cx: &mut crate::VisualTestContext,
) {
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
let input = state.input().clone();
input.update(cx, |state, cx| {
crate::EntityInputHandler::replace_text_in_range(state, None, text, window, cx);
});
});
});
}
#[rgpui::test]
fn attach_extension_registry_flow(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, "hi\n"));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
editor.update(cx, |state, cx| {
assert!(!state.attach_extension("test-only-missing", cx));
});
});
register_editor_extension(
"test-only-counting",
Box::new(|| {
Box::new(CountingExtension {
events: Arc::new(Mutex::new(Vec::new())),
}) as Box<dyn EditorExtension>
}),
);
cx.update(|_, cx| {
editor.update(cx, |state, cx| {
assert!(state.attach_extension("test-only-counting", cx));
assert!(!state.attach_extension("test-only-counting", cx));
});
});
assert_eq!(
editor.read_with(cx, |state, _| state.attached_extensions()),
vec![SharedString::from("test-only-counting")]
);
}
#[rgpui::test]
fn edit_event_fires_to_extension_and_handler(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, "hi\n"));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
let seen: Arc<Mutex<Vec<EditEvent>>> = Arc::new(Mutex::new(Vec::new()));
let seen_for_hook = seen.clone();
cx.update(|_, cx| {
editor.update(cx, |state, _cx| {
let id = state.on_edit(move |event, _, _| {
seen_for_hook.lock().unwrap().push(*event);
});
assert!(!state.remove_on_edit(id.wrapping_add(1)));
assert!(state.remove_on_edit(id));
let seen = seen.clone();
let _ = state.on_edit(move |event, _, _| {
seen.lock().unwrap().push(*event);
});
});
});
type_text(&editor, "!", cx);
cx.run_until_parked();
let events = seen.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].cursor, 4);
assert_eq!(events[0].text_len, 4);
assert_eq!(editor.read_with(cx, |state, cx| state.text(cx)), "hi\n!");
}
#[rgpui::test]
fn attached_extension_receives_edit(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, "hi\n"));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
let events: Arc<Mutex<Vec<EditEvent>>> = Arc::new(Mutex::new(Vec::new()));
let events_for_factory = events.clone();
register_editor_extension(
"test-only-wired",
Box::new(move || {
Box::new(CountingExtension {
events: events_for_factory.clone(),
}) as Box<dyn EditorExtension>
}),
);
cx.update(|_, cx| {
editor.update(cx, |state, cx| {
assert!(state.attach_extension("test-only-wired", cx));
});
});
type_text(&editor, "?", cx);
cx.run_until_parked();
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].cursor, 4);
}
}