pub mod query;
mod window;
pub use window::{
FactSource, Frame, Lifecycle, Presentation, SizeState, Visibility, WindowAction, WindowFacts,
WindowInfo,
};
use bern_protocol::{CommandError, SessionCommand, Type, TypeTable, Value};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
pub struct SessionType {
pub name: String,
pub fields: Vec<(String, Type, Value)>,
}
#[derive(Clone, Debug)]
pub struct WindowKind {
pub id: String,
pub sessions: Vec<usize>,
pub presentation: Presentation,
}
#[derive(Clone, Debug)]
pub struct SessionSpec {
pub types: TypeTable,
pub sessions: Vec<SessionType>,
pub windows: Vec<WindowKind>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WindowInstance {
pub kind: usize,
pub generation: u64,
pub id: String,
pub drafts: Vec<Vec<Value>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CloseDecision {
Save,
Discard,
Cancel,
}
impl CloseDecision {
pub const ALL: [CloseDecision; 3] = [
CloseDecision::Save,
CloseDecision::Discard,
CloseDecision::Cancel,
];
pub fn index(self) -> u32 {
Self::ALL
.iter()
.position(|decision| *decision == self)
.unwrap() as u32
}
pub fn from_index(index: u32) -> Option<Self> {
Self::ALL.get(index as usize).copied()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Confirmation {
pub id: String,
pub window: String,
pub generation: u64,
pub draft_revision: u64,
pub can_save: bool,
}
impl Confirmation {
pub fn decisions(&self) -> Vec<CloseDecision> {
CloseDecision::ALL
.into_iter()
.filter(|decision| self.can_save || *decision != CloseDecision::Save)
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CloseStep {
Close,
Confirm(Confirmation),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecisionStep {
Close,
Save,
Cancel,
}
#[derive(Debug)]
struct WindowState {
generation: u64,
open: bool,
drafts: Vec<Vec<Value>>,
revision: u64,
pending: Option<Confirmation>,
facts: Option<WindowFacts>,
fact_version: u64,
fact_source: Option<FactSource>,
}
#[derive(Debug)]
pub struct UiSession {
session_id: String,
spec: Arc<SessionSpec>,
windows: Vec<WindowState>,
revision: u64,
confirmations: u64,
}
fn stale() -> CommandError {
CommandError::new(
"stale_window",
"the window instance was closed; use the current instance",
)
}
fn stale_confirmation(message: &str) -> CommandError {
CommandError::new("stale_confirmation", message)
}
impl UiSession {
pub fn new(session_id: impl Into<String>, spec: Arc<SessionSpec>) -> Self {
let windows = spec
.windows
.iter()
.map(|_| WindowState {
generation: 0,
open: false,
drafts: Vec::new(),
revision: 0,
pending: None,
facts: None,
fact_version: 0,
fact_source: None,
})
.collect();
Self {
session_id: session_id.into(),
spec,
windows,
revision: 0,
confirmations: 0,
}
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn spec(&self) -> &SessionSpec {
&self.spec
}
pub fn revision(&self) -> u64 {
self.revision
}
fn initial_drafts(&self, kind: usize) -> Vec<Vec<Value>> {
self.spec.windows[kind]
.sessions
.iter()
.map(|session| {
self.spec.sessions[*session]
.fields
.iter()
.map(|(_, _, initial)| initial.clone())
.collect()
})
.collect()
}
fn instance(&self, kind: usize) -> WindowInstance {
let state = &self.windows[kind];
WindowInstance {
kind,
generation: state.generation,
id: format!("{}#{}", self.spec.windows[kind].id, state.generation),
drafts: state.drafts.clone(),
}
}
pub fn window_opened(&mut self, kind: usize) -> Result<WindowInstance, CommandError> {
if kind >= self.windows.len() {
return Err(CommandError::new("target_not_found", "unknown window kind"));
}
if !self.windows[kind].open {
let drafts = self.initial_drafts(kind);
let state = &mut self.windows[kind];
state.generation += 1;
state.open = true;
state.drafts = drafts;
state.revision = 0;
state.pending = None;
state.facts = None;
state.fact_version = 0;
state.fact_source = None;
self.revision += 1;
}
Ok(self.instance(kind))
}
pub fn window_facts(
&mut self,
kind: usize,
generation: u64,
facts: WindowFacts,
source: FactSource,
) -> Result<u64, CommandError> {
let state = self.open_state(kind, generation)?;
if state.facts.as_ref() != Some(&facts) {
state.facts = Some(facts);
state.fact_version += 1;
state.fact_source = Some(source);
}
Ok(state.fact_version)
}
pub fn window_info(&self, kind: usize) -> Option<WindowInfo> {
let state = self.windows.get(kind).filter(|state| state.open)?;
let lifecycle = if state.pending.is_some() {
Lifecycle::Closing
} else if state.facts.is_none() {
Lifecycle::Opening
} else {
Lifecycle::Open
};
Some(WindowInfo {
window: self.instance(kind).id,
kind: self.spec.windows[kind].id.clone(),
presentation: self.spec.windows[kind].presentation,
lifecycle,
facts: state.facts.clone(),
version: state.fact_version,
changed_by: state.fact_source,
})
}
pub fn window_infos(&self) -> Vec<WindowInfo> {
(0..self.windows.len())
.filter_map(|kind| self.window_info(kind))
.collect()
}
pub fn check_action(
&self,
window: &str,
action: WindowAction,
) -> Result<WindowInstance, CommandError> {
let instance = self.find_window(window).ok_or_else(|| {
CommandError::new("target_not_found", format!("no open window `{window}`"))
})?;
let Some(facts) = &self.windows[instance.kind].facts else {
return Err(CommandError::new(
"target_unavailable",
format!("`{window}` is still opening"),
));
};
if !facts.actions.contains(&action) {
let available: Vec<_> = facts.actions.iter().map(|action| action.name()).collect();
return Err(CommandError::new(
"target_unavailable",
format!(
"`{window}` cannot {} now; available: {}",
action.name(),
if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
}
),
));
}
Ok(instance)
}
pub fn window_closed(&mut self, kind: usize, generation: u64) -> bool {
match self.windows.get_mut(kind) {
Some(state) if state.open && state.generation == generation => {
state.open = false;
state.drafts.clear();
state.pending = None;
self.revision += 1;
true
}
_ => false,
}
}
pub fn open_windows(&self) -> Vec<WindowInstance> {
(0..self.windows.len())
.filter(|kind| self.windows[*kind].open)
.map(|kind| self.instance(kind))
.collect()
}
pub fn find_window(&self, id: &str) -> Option<WindowInstance> {
self.open_windows()
.into_iter()
.find(|window| window.id == id)
}
pub fn window_kind(&self, id: &str) -> Option<usize> {
self.spec.windows.iter().position(|kind| kind.id == id)
}
fn slot(&self, kind: usize, generation: u64, session: usize) -> Result<usize, CommandError> {
let state = self
.windows
.get(kind)
.ok_or_else(|| CommandError::new("target_not_found", "unknown window kind"))?;
if !state.open || state.generation != generation {
return Err(stale());
}
self.spec.windows[kind]
.sessions
.iter()
.position(|candidate| *candidate == session)
.ok_or_else(|| {
CommandError::new(
"target_not_found",
"this window does not own that session state",
)
})
}
pub fn set(
&mut self,
kind: usize,
generation: u64,
session: usize,
field: usize,
value: Value,
) -> Result<u64, CommandError> {
let slot = self.slot(kind, generation, session)?;
let session_type = &self.spec.sessions[session];
let (name, ty, _) = session_type
.fields
.get(field)
.ok_or_else(|| CommandError::new("target_not_found", "unknown session field"))?;
value.check(ty, &self.spec.types).map_err(|error| {
CommandError::invalid_params(format!("{}.{name}: {error}", session_type.name))
})?;
let state = &mut self.windows[kind];
let current = &mut state.drafts[slot][field];
if *current != value {
*current = value;
state.revision += 1;
self.revision += 1;
}
Ok(self.revision)
}
fn open_state(
&mut self,
kind: usize,
generation: u64,
) -> Result<&mut WindowState, CommandError> {
let state = self
.windows
.get_mut(kind)
.ok_or_else(|| CommandError::new("target_not_found", "unknown window kind"))?;
if !state.open || state.generation != generation {
return Err(stale());
}
Ok(state)
}
pub fn draft_revision(&self, kind: usize) -> Option<u64> {
self.windows
.get(kind)
.filter(|state| state.open)
.map(|state| state.revision)
}
pub fn pending_close(&self, kind: usize) -> Option<&Confirmation> {
self.windows
.get(kind)
.filter(|state| state.open)
.and_then(|state| state.pending.as_ref())
}
pub fn request_close(
&mut self,
kind: usize,
generation: u64,
needs_confirmation: bool,
can_save: bool,
) -> Result<CloseStep, CommandError> {
let window = format!(
"{}#{generation}",
self.spec
.windows
.get(kind)
.map(|kind| kind.id.as_str())
.unwrap_or_default()
);
let state = self.open_state(kind, generation)?;
if let Some(pending) = &state.pending {
if pending.draft_revision == state.revision {
return Ok(CloseStep::Confirm(pending.clone()));
}
state.pending = None;
}
if !needs_confirmation {
return Ok(CloseStep::Close);
}
let revision = state.revision;
self.confirmations += 1;
let confirmation = Confirmation {
id: format!("{window}:c{}", self.confirmations),
window,
generation,
draft_revision: revision,
can_save,
};
self.windows[kind].pending = Some(confirmation.clone());
Ok(CloseStep::Confirm(confirmation))
}
pub fn confirm_close(
&mut self,
kind: usize,
generation: u64,
confirmation: &str,
decision: CloseDecision,
) -> Result<DecisionStep, CommandError> {
let state = self.open_state(kind, generation)?;
let Some(pending) = state.pending.clone() else {
return Err(stale_confirmation(
"no close confirmation is pending for this window; request closing again",
));
};
if pending.id != confirmation {
return Err(stale_confirmation(
"this is not the window's current close confirmation",
));
}
if pending.draft_revision != state.revision {
state.pending = None;
return Err(stale_confirmation(
"the window's drafts changed after this confirmation; request closing again",
));
}
if decision == CloseDecision::Save && !pending.can_save {
return Err(CommandError::invalid_params(
"this window declares no save command; decide Discard or Cancel",
));
}
state.pending = None;
Ok(match decision {
CloseDecision::Save => DecisionStep::Save,
CloseDecision::Discard => DecisionStep::Close,
CloseDecision::Cancel => DecisionStep::Cancel,
})
}
pub fn get(
&self,
kind: usize,
generation: u64,
session: usize,
field: usize,
) -> Result<Value, CommandError> {
let slot = self.slot(kind, generation, session)?;
self.windows[kind].drafts[slot]
.get(field)
.cloned()
.ok_or_else(|| CommandError::new("target_not_found", "unknown session field"))
}
}
pub trait SessionHandler: Send + Sync {
fn session_id(&self) -> String;
fn windows(&self) -> Vec<String>;
fn handle(&self, command: SessionCommand, params: Vec<Value>) -> Result<Value, CommandError>;
}
#[derive(Clone, Default)]
pub struct Sessions {
inner: Arc<Mutex<BTreeMap<String, Arc<dyn SessionHandler>>>>,
}
impl Sessions {
pub fn new() -> Self {
Self::default()
}
pub fn register(&self, handler: Arc<dyn SessionHandler>) {
self.inner
.lock()
.expect("sessions lock")
.insert(handler.session_id(), handler);
}
pub fn unregister(&self, session_id: &str) {
self.inner.lock().expect("sessions lock").remove(session_id);
}
pub fn list(&self) -> Vec<Arc<dyn SessionHandler>> {
self.inner
.lock()
.expect("sessions lock")
.values()
.cloned()
.collect()
}
pub fn handle(
&self,
session_id: Option<&str>,
command: SessionCommand,
params: Vec<Value>,
) -> Result<Value, CommandError> {
let handler = {
let sessions = self.inner.lock().expect("sessions lock");
match session_id {
Some(id) => sessions.get(id).cloned().ok_or_else(|| {
CommandError::new("session_not_found", format!("no live UI session `{id}`"))
})?,
None => match sessions.len() {
0 => {
return Err(CommandError::new(
"requires_ui_session",
format!("`{}` needs a live UI session", command.id()),
));
}
1 => sessions.values().next().unwrap().clone(),
_ => {
return Err(CommandError::new(
"session_required",
"several UI sessions are live; name one with session_id",
));
}
},
}
};
handler.handle(command, params)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> Arc<SessionSpec> {
Arc::new(SessionSpec {
types: TypeTable::default(),
sessions: vec![SessionType {
name: "Draft".into(),
fields: vec![
("name".into(), Type::Text, Value::Text(String::new())),
("volume".into(), Type::Float, Value::Float(50.0)),
],
}],
windows: vec![
WindowKind {
id: "main".into(),
sessions: vec![0],
presentation: Presentation::Native,
},
WindowKind {
id: "settings".into(),
sessions: vec![],
presentation: Presentation::Native,
},
],
})
}
#[test]
fn drafts_are_window_scoped_and_reset_on_reopen() {
let mut session = UiSession::new("s", spec());
let first = session.window_opened(0).unwrap();
assert_eq!(first.id, "main#1");
assert_eq!(first.drafts[0][1], Value::Float(50.0));
session.set(0, 1, 0, 0, Value::Text("Ada".into())).unwrap();
assert_eq!(
session.window_opened(0).unwrap().drafts[0][0],
Value::Text("Ada".into())
);
assert!(session.window_closed(0, 1));
let second = session.window_opened(0).unwrap();
assert_eq!(second.id, "main#2");
assert_eq!(second.drafts[0][0], Value::Text(String::new()));
}
#[test]
fn writes_are_schema_checked_and_stale_windows_rejected() {
let mut session = UiSession::new("s", spec());
session.window_opened(0).unwrap();
let error = session
.set(0, 1, 0, 1, Value::Text("loud".into()))
.unwrap_err();
assert_eq!(error.code, "invalid_params");
session.window_closed(0, 1);
session.window_opened(0).unwrap();
assert_eq!(
session
.set(0, 1, 0, 0, Value::Text("x".into()))
.unwrap_err()
.code,
"stale_window"
);
assert!(!session.window_closed(0, 1));
assert_eq!(
session
.set(1, 0, 0, 0, Value::Text("x".into()))
.unwrap_err()
.code,
"stale_window"
);
}
fn facts(visibility: Visibility, actions: &[WindowAction]) -> WindowFacts {
WindowFacts {
visibility,
size: SizeState::Normal,
frame: Frame {
x: 10.0,
y: 40.0,
width: 920.0,
height: 640.0,
},
focused: true,
actions: actions.to_vec(),
}
}
#[test]
fn window_facts_are_versioned_per_instance_and_reject_stale_generations() {
let mut session = UiSession::new("s", spec());
session.window_opened(0).unwrap();
let info = session.window_info(0).unwrap();
assert_eq!(info.lifecycle, Lifecycle::Opening);
assert_eq!(info.version, 0);
let visible = facts(Visibility::Visible, &[WindowAction::Minimize]);
assert_eq!(
session.window_facts(0, 1, visible.clone(), FactSource::System),
Ok(1)
);
assert_eq!(
session.window_facts(0, 1, visible, FactSource::Command),
Ok(1)
);
let minimized = facts(Visibility::Minimized, &[WindowAction::Restore]);
assert_eq!(
session.window_facts(0, 1, minimized.clone(), FactSource::Command),
Ok(2)
);
let info = session.window_info(0).unwrap();
assert_eq!(info.lifecycle, Lifecycle::Open);
assert_eq!(info.changed_by, Some(FactSource::Command));
assert_eq!(
info.facts.as_ref().unwrap().visibility,
Visibility::Minimized
);
session.window_closed(0, 1);
session.window_opened(0).unwrap();
assert_eq!(
session
.window_facts(0, 1, minimized, FactSource::System)
.unwrap_err()
.code,
"stale_window"
);
let info = session.window_info(0).unwrap();
assert_eq!((info.window.as_str(), info.version), ("main#2", 0));
assert!(info.facts.is_none());
}
#[test]
fn window_actions_follow_the_latest_facts() {
let mut session = UiSession::new("s", spec());
session.window_opened(1).unwrap();
assert_eq!(
session
.check_action("settings#1", WindowAction::Move)
.unwrap_err()
.message,
"`settings#1` is still opening"
);
session
.window_facts(
1,
1,
facts(
Visibility::Visible,
&[WindowAction::Activate, WindowAction::Move],
),
FactSource::System,
)
.unwrap();
assert_eq!(
session
.check_action("settings#1", WindowAction::Move)
.unwrap()
.id,
"settings#1"
);
let error = session
.check_action("settings#1", WindowAction::Minimize)
.unwrap_err();
assert_eq!(error.code, "target_unavailable");
assert_eq!(
error.message,
"`settings#1` cannot minimize now; available: activate, move"
);
assert_eq!(
session
.check_action("main#1", WindowAction::Minimize)
.unwrap_err()
.code,
"target_not_found"
);
session.request_close(1, 1, true, false).unwrap();
assert_eq!(
session.window_info(1).unwrap().lifecycle,
Lifecycle::Closing
);
}
#[test]
fn window_info_matches_the_session_catalog() {
let (types, specs) = bern_protocol::session_catalog();
let result = &specs
.iter()
.find(|spec| spec.id == "window.list")
.unwrap()
.result;
let mut session = UiSession::new("s", spec());
session.window_opened(0).unwrap();
session.window_opened(1).unwrap();
session
.window_facts(
0,
1,
facts(Visibility::Visible, &WindowAction::ALL),
FactSource::System,
)
.unwrap();
let list = Value::List(
session
.window_infos()
.iter()
.map(WindowInfo::to_value)
.collect(),
);
list.check(result, &types).unwrap();
}
#[test]
fn closing_without_a_failing_check_closes_at_once() {
let mut session = UiSession::new("s", spec());
session.window_opened(1).unwrap();
assert_eq!(
session.request_close(1, 1, false, false),
Ok(CloseStep::Close)
);
assert_eq!(
session.request_close(1, 7, false, false).unwrap_err().code,
"stale_window"
);
}
#[test]
fn confirmations_merge_and_bind_to_generation_and_revision() {
let mut session = UiSession::new("s", spec());
session.window_opened(0).unwrap();
session
.set(0, 1, 0, 0, Value::Text("draft".into()))
.unwrap();
let CloseStep::Confirm(first) = session.request_close(0, 1, true, true).unwrap() else {
panic!("expected a confirmation")
};
assert_eq!(first.window, "main#1");
assert_eq!(first.draft_revision, 1);
assert_eq!(
first.decisions(),
vec![
CloseDecision::Save,
CloseDecision::Discard,
CloseDecision::Cancel
]
);
assert_eq!(
session.request_close(0, 1, true, true),
Ok(CloseStep::Confirm(first.clone()))
);
assert!(session.pending_close(0).is_some());
assert_eq!(
session.confirm_close(0, 1, &first.id, CloseDecision::Cancel),
Ok(DecisionStep::Cancel)
);
assert!(session.pending_close(0).is_none());
assert_eq!(
session
.confirm_close(0, 1, &first.id, CloseDecision::Discard)
.unwrap_err()
.code,
"stale_confirmation"
);
let CloseStep::Confirm(second) = session.request_close(0, 1, true, true).unwrap() else {
panic!()
};
assert_ne!(second.id, first.id);
session.set(0, 1, 0, 0, Value::Text("more".into())).unwrap();
assert_eq!(
session
.confirm_close(0, 1, &second.id, CloseDecision::Discard)
.unwrap_err()
.code,
"stale_confirmation"
);
let CloseStep::Confirm(third) = session.request_close(0, 1, true, true).unwrap() else {
panic!()
};
assert_eq!(third.draft_revision, 2);
assert_eq!(
session.confirm_close(0, 1, &third.id, CloseDecision::Save),
Ok(DecisionStep::Save)
);
}
#[test]
fn confirmations_end_with_their_window_instance() {
let mut session = UiSession::new("s", spec());
session.window_opened(0).unwrap();
let CloseStep::Confirm(old) = session.request_close(0, 1, true, false).unwrap() else {
panic!()
};
assert_eq!(
session
.confirm_close(0, 1, &old.id, CloseDecision::Save)
.unwrap_err()
.code,
"invalid_params"
);
assert_eq!(
session.confirm_close(0, 1, &old.id, CloseDecision::Discard),
Ok(DecisionStep::Close)
);
assert!(session.window_closed(0, 1));
session.window_opened(0).unwrap();
assert_eq!(
session
.confirm_close(0, 1, &old.id, CloseDecision::Discard)
.unwrap_err()
.code,
"stale_window"
);
assert_eq!(
session
.confirm_close(0, 2, &old.id, CloseDecision::Discard)
.unwrap_err()
.code,
"stale_confirmation"
);
}
struct Echo(String);
impl SessionHandler for Echo {
fn session_id(&self) -> String {
self.0.clone()
}
fn windows(&self) -> Vec<String> {
vec![]
}
fn handle(&self, command: SessionCommand, _: Vec<Value>) -> Result<Value, CommandError> {
Ok(Value::Text(format!("{}:{}", self.0, command.id())))
}
}
#[test]
fn routing_requires_an_unambiguous_live_session() {
let sessions = Sessions::new();
assert_eq!(
sessions
.handle(None, SessionCommand::Inspect, vec![])
.unwrap_err()
.code,
"requires_ui_session"
);
sessions.register(Arc::new(Echo("a".into())));
assert_eq!(
sessions
.handle(None, SessionCommand::Inspect, vec![])
.unwrap(),
Value::Text("a:ui.inspect".into())
);
sessions.register(Arc::new(Echo("b".into())));
assert_eq!(
sessions
.handle(None, SessionCommand::Inspect, vec![])
.unwrap_err()
.code,
"session_required"
);
assert!(
sessions
.handle(Some("b"), SessionCommand::Back, vec![])
.is_ok()
);
assert_eq!(
sessions
.handle(Some("z"), SessionCommand::Back, vec![])
.unwrap_err()
.code,
"session_not_found"
);
}
}