use crate::css::StyleSheet;
use crate::widget_tree::WidgetTree;
use crate::widgets::Widget;
use rich_rs::{Console, ConsoleOptions, Segments};
use std::fs;
pub trait Screen: Send + Sync {
fn name(&self) -> &str {
"Screen"
}
fn compose(&self) -> Box<dyn Widget>;
fn css(&self) -> Option<&str> {
None
}
fn on_mount(&mut self) {}
fn on_suspend(&mut self) {}
fn on_resume(&mut self) {}
fn on_unmount(&mut self) {}
fn is_modal(&self) -> bool {
true
}
fn title(&self) -> Option<&str> {
None
}
fn sub_title(&self) -> Option<&str> {
None
}
}
pub enum ScreenResult {
Dismissed,
Value(Box<dyn std::any::Any + Send>),
}
pub type ScreenResultCallback = Box<dyn FnOnce(ScreenResult) + Send>;
const MODAL_SCREEN_ALIASES: &[&str] = &["Screen"];
struct ScreenHost {
modal: bool,
child: Option<Box<dyn Widget>>,
}
impl ScreenHost {
fn new(modal: bool, child: Box<dyn Widget>) -> Self {
Self {
modal,
child: Some(child),
}
}
}
impl Widget for ScreenHost {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn take_composed_children(&mut self) -> Vec<Box<dyn Widget>> {
self.child.take().into_iter().collect()
}
fn style_type(&self) -> &'static str {
if self.modal { "ModalScreen" } else { "Screen" }
}
fn style_type_aliases(&self) -> &[&'static str] {
if self.modal {
MODAL_SCREEN_ALIASES
} else {
&[]
}
}
}
pub(crate) struct ScreenEntry {
pub screen: Box<dyn Screen>,
#[allow(dead_code)]
pub widget_tree: WidgetTree,
#[allow(dead_code)]
pub stylesheet: Option<StyleSheet>,
result_callback: Option<ScreenResultCallback>,
pending_result: Option<ScreenResult>,
pub(crate) mode_name: Option<String>,
}
pub struct ScreenStack {
screens: Vec<ScreenEntry>,
}
impl ScreenStack {
pub fn new() -> Self {
Self {
screens: Vec::new(),
}
}
pub fn push(&mut self, screen: Box<dyn Screen>) {
self.push_inner(screen, None, None);
}
pub fn push_with_callback(&mut self, screen: Box<dyn Screen>, callback: ScreenResultCallback) {
self.push_inner(screen, Some(callback), None);
}
pub fn push_mode(&mut self, screen: Box<dyn Screen>, mode_name: String) {
self.push_inner(screen, None, Some(mode_name));
}
pub fn pop_mode(&mut self, mode_name: &str) -> Option<String> {
let idx = self
.screens
.iter()
.position(|e| e.mode_name.as_deref() == Some(mode_name))?;
let mut entry = self.screens.remove(idx);
entry.screen.on_unmount();
if idx == self.screens.len() {
if let Some(new_top) = self.screens.last_mut() {
new_top.screen.on_resume();
}
}
let result = entry.pending_result.unwrap_or(ScreenResult::Dismissed);
if let Some(callback) = entry.result_callback {
callback(result);
}
entry.mode_name
}
pub fn top_mode_name(&self) -> Option<&str> {
self.screens.last().and_then(|e| e.mode_name.as_deref())
}
fn push_inner(
&mut self,
mut screen: Box<dyn Screen>,
callback: Option<ScreenResultCallback>,
mode_name: Option<String>,
) {
if let Some(top) = self.screens.last_mut() {
top.screen.on_suspend();
}
let modal = screen.is_modal();
let root_widget = Box::new(ScreenHost::new(modal, screen.compose()));
let mut widget_tree = WidgetTree::new();
let root_id = widget_tree.set_root(root_widget);
let (extracted_children, compose_decls) = widget_tree
.get_mut(root_id)
.map(|node| (node.widget.take_composed_children(), node.widget.compose()))
.unwrap_or_default();
for child in extracted_children {
crate::runtime::App::mount_extracted_recursive(&mut widget_tree, root_id, child);
}
if !compose_decls.is_empty() {
crate::runtime::App::mount_declarations(&mut widget_tree, root_id, compose_decls);
}
crate::runtime::App::mount_system_tooltip(&mut widget_tree, root_id);
let _ = widget_tree.drain_lifecycle();
let stylesheet = screen.css().map(|css| {
let css_text = fs::read_to_string(css).unwrap_or_else(|_| css.to_string());
StyleSheet::parse(&css_text)
});
screen.on_mount();
self.screens.push(ScreenEntry {
screen,
widget_tree,
stylesheet,
result_callback: callback,
pending_result: None,
mode_name,
});
}
pub fn dismiss(&mut self, result: ScreenResult) -> bool {
if let Some(top) = self.screens.last_mut() {
top.pending_result = Some(result);
true
} else {
false
}
}
pub fn pop(&mut self) -> Option<(Box<dyn Screen>, ScreenResult, Option<String>)> {
let mut entry = self.screens.pop()?;
entry.screen.on_unmount();
if let Some(new_top) = self.screens.last_mut() {
new_top.screen.on_resume();
}
let result = entry.pending_result.unwrap_or(ScreenResult::Dismissed);
let mode_name = entry.mode_name;
if let Some(callback) = entry.result_callback {
callback(result);
Some((entry.screen, ScreenResult::Dismissed, mode_name))
} else {
Some((entry.screen, result, mode_name))
}
}
pub(crate) fn top(&self) -> Option<&ScreenEntry> {
self.screens.last()
}
#[allow(dead_code)]
pub(crate) fn top_mut(&mut self) -> Option<&mut ScreenEntry> {
self.screens.last_mut()
}
pub(crate) fn get(&self, index: usize) -> Option<&ScreenEntry> {
self.screens.get(index)
}
pub(crate) fn get_mut(&mut self, index: usize) -> Option<&mut ScreenEntry> {
self.screens.get_mut(index)
}
pub fn len(&self) -> usize {
self.screens.len()
}
pub fn is_empty(&self) -> bool {
self.screens.is_empty()
}
pub fn active_title(&self) -> Option<&str> {
self.top().and_then(|e| e.screen.title())
}
pub fn active_sub_title(&self) -> Option<&str> {
self.top().and_then(|e| e.screen.sub_title())
}
}
impl Default for ScreenStack {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rich_rs::{Console, ConsoleOptions, Segments};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Default)]
struct LifecycleLog {
events: Arc<Mutex<Vec<String>>>,
}
impl LifecycleLog {
fn new() -> Self {
Self {
events: Arc::new(Mutex::new(Vec::new())),
}
}
fn log(&self, event: &str) {
self.events.lock().unwrap().push(event.to_string());
}
fn events(&self) -> Vec<String> {
self.events.lock().unwrap().clone()
}
}
struct StubWidget;
impl Widget for StubWidget {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_type(&self) -> &'static str {
"StubWidget"
}
}
struct TestScreen {
screen_name: String,
log: LifecycleLog,
modal: bool,
css_text: Option<String>,
screen_title: Option<String>,
screen_sub_title: Option<String>,
}
impl TestScreen {
fn new(name: &str, log: LifecycleLog) -> Self {
Self {
screen_name: name.to_string(),
log,
modal: true,
css_text: None,
screen_title: None,
screen_sub_title: None,
}
}
fn with_modal(mut self, modal: bool) -> Self {
self.modal = modal;
self
}
fn with_css(mut self, css: &str) -> Self {
self.css_text = Some(css.to_string());
self
}
fn with_title(mut self, title: &str) -> Self {
self.screen_title = Some(title.to_string());
self
}
fn with_sub_title(mut self, sub_title: &str) -> Self {
self.screen_sub_title = Some(sub_title.to_string());
self
}
fn boxed(name: &str, log: LifecycleLog) -> Box<dyn Screen> {
Box::new(Self::new(name, log))
}
}
impl Screen for TestScreen {
fn name(&self) -> &str {
&self.screen_name
}
fn compose(&self) -> Box<dyn Widget> {
Box::new(StubWidget)
}
fn css(&self) -> Option<&str> {
self.css_text.as_deref()
}
fn on_mount(&mut self) {
self.log.log(&format!("{}:mount", self.screen_name));
}
fn on_suspend(&mut self) {
self.log.log(&format!("{}:suspend", self.screen_name));
}
fn on_resume(&mut self) {
self.log.log(&format!("{}:resume", self.screen_name));
}
fn on_unmount(&mut self) {
self.log.log(&format!("{}:unmount", self.screen_name));
}
fn is_modal(&self) -> bool {
self.modal
}
fn title(&self) -> Option<&str> {
self.screen_title.as_deref()
}
fn sub_title(&self) -> Option<&str> {
self.screen_sub_title.as_deref()
}
}
#[test]
fn new_stack_is_empty() {
let stack = ScreenStack::new();
assert!(stack.is_empty());
assert_eq!(stack.len(), 0);
assert!(stack.top().is_none());
}
#[test]
fn push_increases_len() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("A", log.clone()));
assert_eq!(stack.len(), 1);
assert!(!stack.is_empty());
stack.push(TestScreen::boxed("B", log.clone()));
assert_eq!(stack.len(), 2);
stack.push(TestScreen::boxed("C", log.clone()));
assert_eq!(stack.len(), 3);
}
#[test]
fn pop_returns_screen_and_dismissed_result() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Main", log.clone()));
let result = stack.pop();
assert!(result.is_some());
let (screen, screen_result, _mode) = result.unwrap();
assert_eq!(screen.name(), "Main");
assert!(matches!(screen_result, ScreenResult::Dismissed));
assert!(stack.is_empty());
}
#[test]
fn push_calls_suspend_on_previous_and_mount_on_new() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("First", log.clone()));
assert_eq!(log.events(), vec!["First:mount"]);
stack.push(TestScreen::boxed("Second", log.clone()));
assert_eq!(
log.events(),
vec!["First:mount", "First:suspend", "Second:mount"]
);
}
#[test]
fn pop_calls_unmount_on_popped_and_resume_on_new_top() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Base", log.clone()));
stack.push(TestScreen::boxed("Overlay", log.clone()));
log.events.lock().unwrap().clear();
stack.pop();
assert_eq!(log.events(), vec!["Overlay:unmount", "Base:resume"]);
}
#[test]
fn pop_on_empty_returns_none() {
let mut stack = ScreenStack::new();
assert!(stack.pop().is_none());
}
#[test]
fn top_returns_topmost() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Bottom", log.clone()));
stack.push(TestScreen::boxed("Top", log.clone()));
assert_eq!(stack.top().unwrap().screen.name(), "Top");
}
#[test]
fn top_mut_returns_topmost() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Bottom", log.clone()));
stack.push(TestScreen::boxed("Top", log.clone()));
assert_eq!(stack.top_mut().unwrap().screen.name(), "Top");
}
#[test]
fn screen_result_dismissed() {
let result = ScreenResult::Dismissed;
assert!(matches!(result, ScreenResult::Dismissed));
}
#[test]
fn screen_result_value() {
let result = ScreenResult::Value(Box::new(42i32));
match result {
ScreenResult::Value(val) => {
let num = val.downcast_ref::<i32>().unwrap();
assert_eq!(*num, 42);
}
_ => panic!("expected Value variant"),
}
}
#[test]
fn screen_result_value_string() {
let result = ScreenResult::Value(Box::new("hello".to_string()));
match result {
ScreenResult::Value(val) => {
let s = val.downcast_ref::<String>().unwrap();
assert_eq!(s, "hello");
}
_ => panic!("expected Value variant"),
}
}
#[test]
fn modal_default_is_true() {
let log = LifecycleLog::new();
let screen = TestScreen::new("test", log);
assert!(screen.is_modal());
}
#[test]
fn modal_can_be_overridden() {
let log = LifecycleLog::new();
let screen = TestScreen::new("test", log).with_modal(false);
assert!(!screen.is_modal());
}
#[test]
fn full_lifecycle_ordering() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("A", log.clone()));
stack.push(TestScreen::boxed("B", log.clone()));
stack.push(TestScreen::boxed("C", log.clone()));
stack.pop();
stack.pop();
stack.pop();
assert_eq!(
log.events(),
vec![
"A:mount",
"A:suspend",
"B:mount",
"B:suspend",
"C:mount",
"C:unmount",
"B:resume",
"B:unmount",
"A:resume",
"A:unmount",
]
);
}
#[test]
fn push_builds_widget_tree() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("test", log));
let entry = stack.top().unwrap();
assert!(entry.widget_tree.root().is_some());
assert_eq!(entry.widget_tree.len(), 3);
}
#[test]
fn push_parses_css_stylesheet() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
let screen = TestScreen::new("styled", log).with_css("Button { color: red; }");
stack.push(Box::new(screen));
let entry = stack.top().unwrap();
assert!(entry.stylesheet.is_some());
}
#[test]
fn push_no_css_gives_none_stylesheet() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("plain", log));
let entry = stack.top().unwrap();
assert!(entry.stylesheet.is_none());
}
#[test]
fn screen_default_name() {
struct MinimalScreen;
impl Screen for MinimalScreen {
fn compose(&self) -> Box<dyn Widget> {
Box::new(StubWidget)
}
}
let screen = MinimalScreen;
assert_eq!(screen.name(), "Screen");
assert!(screen.css().is_none());
assert!(screen.is_modal());
assert!(screen.title().is_none());
assert!(screen.sub_title().is_none());
}
#[test]
fn pop_single_screen_no_resume_called() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Only", log.clone()));
log.events.lock().unwrap().clear();
stack.pop();
assert_eq!(log.events(), vec!["Only:unmount"]);
}
#[test]
fn multiple_pushes_suspend_chain() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("A", log.clone()));
stack.push(TestScreen::boxed("B", log.clone()));
stack.push(TestScreen::boxed("C", log.clone()));
stack.push(TestScreen::boxed("D", log.clone()));
assert_eq!(
log.events(),
vec![
"A:mount",
"A:suspend",
"B:mount",
"B:suspend",
"C:mount",
"C:suspend",
"D:mount",
]
);
assert_eq!(stack.len(), 4);
}
#[test]
fn push_with_callback_invokes_on_pop() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
let callback_log = Arc::new(Mutex::new(Vec::<String>::new()));
let cb_log = callback_log.clone();
stack.push_with_callback(
TestScreen::boxed("Dialog", log.clone()),
Box::new(move |result| {
let msg = match result {
ScreenResult::Dismissed => "dismissed".to_string(),
ScreenResult::Value(v) => {
format!("value:{}", v.downcast_ref::<i32>().unwrap())
}
};
cb_log.lock().unwrap().push(msg);
}),
);
stack.pop();
assert_eq!(callback_log.lock().unwrap().as_slice(), &["dismissed"]);
}
#[test]
fn dismiss_with_value_invokes_callback_with_value() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
let callback_log = Arc::new(Mutex::new(Vec::<String>::new()));
let cb_log = callback_log.clone();
stack.push_with_callback(
TestScreen::boxed("Dialog", log.clone()),
Box::new(move |result| {
let msg = match result {
ScreenResult::Dismissed => "dismissed".to_string(),
ScreenResult::Value(v) => {
format!("value:{}", v.downcast_ref::<String>().unwrap())
}
};
cb_log.lock().unwrap().push(msg);
}),
);
stack.dismiss(ScreenResult::Value(Box::new("confirmed".to_string())));
stack.pop();
assert_eq!(
callback_log.lock().unwrap().as_slice(),
&["value:confirmed"]
);
}
#[test]
fn pop_without_callback_returns_pending_result() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
stack.push(TestScreen::boxed("Dialog", log.clone()));
stack.dismiss(ScreenResult::Value(Box::new(42i32)));
let (_, result, _) = stack.pop().unwrap();
match result {
ScreenResult::Value(v) => assert_eq!(*v.downcast_ref::<i32>().unwrap(), 42),
ScreenResult::Dismissed => panic!("expected Value"),
}
}
#[test]
fn dismiss_on_empty_stack_returns_false() {
let mut stack = ScreenStack::new();
assert!(!stack.dismiss(ScreenResult::Dismissed));
}
#[test]
fn callback_receives_dismissed_when_no_pending_result() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
let received = Arc::new(Mutex::new(false));
let received_clone = received.clone();
stack.push_with_callback(
TestScreen::boxed("X", log.clone()),
Box::new(move |result| {
*received_clone.lock().unwrap() = matches!(result, ScreenResult::Dismissed);
}),
);
stack.pop();
assert!(*received.lock().unwrap());
}
#[test]
fn screen_title_default_is_none() {
let log = LifecycleLog::new();
let screen = TestScreen::new("test", log);
assert!(screen.title().is_none());
assert!(screen.sub_title().is_none());
}
#[test]
fn screen_title_can_be_set() {
let log = LifecycleLog::new();
let screen = TestScreen::new("test", log)
.with_title("My App")
.with_sub_title("v1.0");
assert_eq!(screen.title(), Some("My App"));
assert_eq!(screen.sub_title(), Some("v1.0"));
}
#[test]
fn active_title_from_topmost_screen() {
let mut stack = ScreenStack::new();
let log = LifecycleLog::new();
assert!(stack.active_title().is_none());
assert!(stack.active_sub_title().is_none());
stack.push(TestScreen::boxed("Base", log.clone()));
assert!(stack.active_title().is_none());
let titled = TestScreen::new("Settings", log.clone())
.with_title("Settings")
.with_sub_title("General");
stack.push(Box::new(titled));
assert_eq!(stack.active_title(), Some("Settings"));
assert_eq!(stack.active_sub_title(), Some("General"));
stack.pop();
assert!(stack.active_title().is_none());
}
#[test]
fn pushed_modal_screen_root_uses_modal_screen_type_and_preserves_body_widget() {
struct ScreenBody;
impl Widget for ScreenBody {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_type(&self) -> &'static str {
"QuitScreen"
}
}
struct ModalBodyScreen;
impl Screen for ModalBodyScreen {
fn compose(&self) -> Box<dyn Widget> {
Box::new(ScreenBody)
}
}
let mut stack = ScreenStack::new();
stack.push(Box::new(ModalBodyScreen));
let entry = stack.top().expect("top screen should exist");
let root_id = entry
.widget_tree
.root()
.expect("screen tree root should exist");
let root = entry
.widget_tree
.get(root_id)
.expect("root node should exist");
assert_eq!(root.widget.style_type(), "ModalScreen");
assert!(
root.widget.style_type_aliases().contains(&"Screen"),
"ModalScreen root should also match Screen selectors"
);
let child_id = *entry
.widget_tree
.children(root_id)
.first()
.expect("modal root should host the composed body widget");
let child = entry
.widget_tree
.get(child_id)
.expect("body widget should exist");
assert_eq!(
child.widget.style_type(),
"QuitScreen",
"screen body widget type should remain available for screen-specific selectors"
);
}
}