use std::any::Any;
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::a11y::A11yCapability;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionError {
EngineNotFound(String),
SpawnFailed(String),
Unsupported(String),
}
impl fmt::Display for SessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EngineNotFound(id) => write!(f, "session engine not registered: {id}"),
Self::SpawnFailed(reason) => write!(f, "session spawn failed: {reason}"),
Self::Unsupported(reason) => write!(f, "unsupported: {reason}"),
}
}
}
impl std::error::Error for SessionError {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSpawnRequest {
pub address: String,
pub body: Option<String>,
pub content_type: Option<String>,
pub viewport: (u32, u32),
pub hidden: bool,
}
impl SessionSpawnRequest {
pub fn new(address: impl Into<String>) -> Self {
Self {
address: address.into(),
body: None,
content_type: None,
viewport: (0, 0),
hidden: false,
}
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = Some(content_type.into());
self
}
pub fn with_viewport(mut self, width: u32, height: u32) -> Self {
self.viewport = (width, height);
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SessionLink {
pub url: String,
pub rect: [f32; 4],
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionClick {
Navigate(String),
Handled,
Miss,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionScrollKey {
LineUp,
LineDown,
PageUp,
PageDown,
Home,
End,
}
pub trait SessionEngine<F>: Send + Sync {
fn engine_id(&self) -> &str;
fn spawn(&self, request: &SessionSpawnRequest)
-> Result<Box<dyn DocumentSession<F>>, SessionError>;
fn a11y_capability(&self) -> A11yCapability {
A11yCapability::Partial
}
}
pub trait DocumentSession<F>: Any {
fn frame(&mut self, width: u32, height: u32) -> F;
fn scroll_by(&mut self, dx: f32, dy: f32) -> bool;
fn scroll_at(&mut self, _x: f32, _y: f32, dx: f32, dy: f32) -> bool {
self.scroll_by(dx, dy)
}
fn scroll_for_key(&mut self, key: SessionScrollKey) -> bool;
fn scroll_to(&mut self, _y: f32) {}
fn click_at(&mut self, x: f32, y: f32) -> SessionClick;
fn links(&self) -> Vec<SessionLink>;
fn content_height(&mut self, _width: u32, height: u32) -> u32 {
height
}
fn pump(&mut self, _now_ms: f64) {}
fn settled(&mut self) -> bool {
true
}
fn set_hidden(&mut self, _hidden: bool) {}
fn as_any(&mut self) -> &mut dyn Any;
}
#[derive(Default)]
pub struct SessionRegistry<F> {
engines: HashMap<String, Box<dyn SessionEngine<F>>>,
}
impl<F> SessionRegistry<F> {
pub fn new() -> Self {
Self {
engines: HashMap::new(),
}
}
pub fn register(&mut self, engine: Box<dyn SessionEngine<F>>) {
self.engines.insert(engine.engine_id().to_string(), engine);
}
pub fn contains(&self, engine_id: &str) -> bool {
self.engines.contains_key(engine_id)
}
pub fn get(&self, engine_id: &str) -> Option<&dyn SessionEngine<F>> {
self.engines.get(engine_id).map(|e| e.as_ref())
}
pub fn spawn(
&self,
engine_id: &str,
request: &SessionSpawnRequest,
) -> Result<Box<dyn DocumentSession<F>>, SessionError> {
self.engines
.get(engine_id)
.ok_or_else(|| SessionError::EngineNotFound(engine_id.to_string()))?
.spawn(request)
}
pub fn engine_ids(&self) -> impl Iterator<Item = &str> {
self.engines.keys().map(String::as_str)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EngineKinds {
pub document: bool,
pub session: bool,
pub surface: bool,
}
impl EngineKinds {
pub fn any(&self) -> bool {
self.document || self.session || self.surface
}
}
#[derive(Clone, Debug, Default)]
pub struct EngineKindIndex {
kinds: HashMap<String, EngineKinds>,
}
impl EngineKindIndex {
pub fn build<'a>(
document_ids: impl IntoIterator<Item = &'a str>,
session_ids: impl IntoIterator<Item = &'a str>,
surface_ids: impl IntoIterator<Item = &'a str>,
) -> Self {
let mut kinds: HashMap<String, EngineKinds> = HashMap::new();
for id in document_ids {
kinds.entry(id.to_string()).or_default().document = true;
}
for id in session_ids {
kinds.entry(id.to_string()).or_default().session = true;
}
for id in surface_ids {
kinds.entry(id.to_string()).or_default().surface = true;
}
Self { kinds }
}
pub fn kinds_of(&self, engine_id: &str) -> EngineKinds {
self.kinds.get(engine_id).copied().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
type TextFrame = String;
struct EchoSession {
address: String,
scroll: f32,
hidden: bool,
}
impl DocumentSession<TextFrame> for EchoSession {
fn frame(&mut self, width: u32, height: u32) -> TextFrame {
format!("{} @ {width}x{height} scroll={}", self.address, self.scroll)
}
fn scroll_by(&mut self, _dx: f32, dy: f32) -> bool {
self.scroll += dy;
dy != 0.0
}
fn scroll_for_key(&mut self, key: SessionScrollKey) -> bool {
self.scroll_by(0.0, if key == SessionScrollKey::PageDown { 100.0 } else { 0.0 })
}
fn click_at(&mut self, x: f32, _y: f32) -> SessionClick {
if x < 10.0 {
SessionClick::Navigate("gemini://example.test/".into())
} else {
SessionClick::Miss
}
}
fn links(&self) -> Vec<SessionLink> {
vec![SessionLink {
url: "gemini://example.test/".into(),
rect: [0.0, 0.0, 10.0, 10.0],
}]
}
fn set_hidden(&mut self, hidden: bool) {
self.hidden = hidden;
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
struct EchoSessionEngine;
impl SessionEngine<TextFrame> for EchoSessionEngine {
fn engine_id(&self) -> &str {
"echo.session"
}
fn spawn(
&self,
request: &SessionSpawnRequest,
) -> Result<Box<dyn DocumentSession<TextFrame>>, SessionError> {
if request.address.is_empty() {
return Err(SessionError::SpawnFailed("empty address".into()));
}
Ok(Box::new(EchoSession {
address: request.address.clone(),
scroll: 0.0,
hidden: request.hidden,
}))
}
}
#[test]
fn registry_spawns_and_drives_a_session() {
let mut registry = SessionRegistry::new();
registry.register(Box::new(EchoSessionEngine));
let request = SessionSpawnRequest::new("https://example.test").with_viewport(800, 600);
let mut session = registry.spawn("echo.session", &request).expect("spawns");
assert_eq!(session.frame(800, 600), "https://example.test @ 800x600 scroll=0");
assert!(session.scroll_by(0.0, 42.0));
assert!(session.frame(800, 600).ends_with("scroll=42"));
assert_eq!(
session.click_at(5.0, 5.0),
SessionClick::Navigate("gemini://example.test/".into())
);
assert_eq!(session.click_at(50.0, 5.0), SessionClick::Miss);
assert_eq!(session.links().len(), 1);
assert!(session.settled());
session.pump(16.0);
}
#[test]
fn unknown_engine_is_a_named_error() {
let registry: SessionRegistry<TextFrame> = SessionRegistry::new();
let err = match registry.spawn("nope", &SessionSpawnRequest::new("x")) {
Ok(_) => panic!("unknown engine must not spawn"),
Err(err) => err,
};
assert_eq!(err, SessionError::EngineNotFound("nope".into()));
}
#[test]
fn downcast_reaches_lane_extras() {
let mut registry = SessionRegistry::new();
registry.register(Box::new(EchoSessionEngine));
let mut session = registry
.spawn("echo.session", &SessionSpawnRequest::new("a"))
.unwrap();
session.set_hidden(true);
let echo = session
.as_any()
.downcast_mut::<EchoSession>()
.expect("concrete lane type reachable");
assert!(echo.hidden);
}
#[test]
fn kind_index_reports_flags_not_a_single_kind() {
let mut sessions: SessionRegistry<TextFrame> = SessionRegistry::new();
sessions.register(Box::new(EchoSessionEngine));
let index = EngineKindIndex::build(
["nematic.gemtext"],
sessions.engine_ids().chain(["nematic.gemtext"]),
["scrying.web"],
);
assert!(index.kinds_of("echo.session").session);
let both = index.kinds_of("nematic.gemtext");
assert!(both.document && both.session && !both.surface);
assert!(index.kinds_of("scrying.web").surface);
assert!(!index.kinds_of("absent").any());
}
}