use super::*;
use crate::liosbar as lios_bar;
type Shared = Rc<RefCell<Workspace>>;
struct Entry {
id: SessionId,
name: String,
ui: Rc<SessionUi>,
close_dialog: Option<glib::WeakRef<gtk::MessageDialog>>,
}
struct Workspace {
window: glib::WeakRef<gtk::ApplicationWindow>,
deck: SessionWorkspace,
config_path: Option<PathBuf>,
layout_baseline: crate::config::WorkspaceSettings,
entries: Vec<Entry>,
next_number: u64,
close: CloseGuardState,
close_dialog: Option<glib::WeakRef<gtk::MessageDialog>>,
shutting_down: bool,
}
pub(super) fn build_workspace_window(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) {
build_workspace(app, launch, settings, config_path, true);
}
fn build_workspace(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
spawn: bool,
) -> (gtk::ApplicationWindow, Shared) {
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("LiosBar — Lios")
.default_width(settings.window.default_width.saturating_add(220))
.default_height(settings.window.default_height)
.decorated(settings.window.decorated)
.icon_name(icon_name())
.build();
window.add_css_class("lios-window");
window.add_css_class("lios-workspace");
apply_window_opacity(&window, settings.window.opacity);
if settings.window.decorated {
window.set_titlebar(Some(&build_headerbar()));
}
let deck = SessionWorkspace::new(settings.workspace.sidebar);
deck.set_preferences(settings.workspace.preferences);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let tools = gtk::Box::new(gtk::Orientation::Horizontal, 8);
tools.add_css_class("lios-workspace-tools");
let title = gtk::Label::new(Some("LIOSBAR"));
title.set_hexpand(true);
title.set_halign(gtk::Align::Start);
tools.append(&title);
let preferences = gtk::Button::with_label("Preferences");
preferences.set_action_name(Some("term.customize"));
tools.append(&preferences);
let rename = gtk::Button::with_label("Rename");
rename.set_tooltip_text(Some("Rename active session (Ctrl+Shift+R)"));
tools.append(&rename);
let side = gtk::Button::with_label(match settings.workspace.sidebar {
SidebarSide::Left => "Sidebar: Left",
SidebarSide::Right => "Sidebar: Right",
});
side.set_tooltip_text(Some("Move sidebar (Ctrl+Shift+B)"));
side.set_widget_name("liosbar-sidebar-side");
tools.append(&side);
root.append(&tools);
root.append(deck.widget());
window.set_child(Some(&root));
let state = Rc::new(RefCell::new(Workspace {
window: window.downgrade(),
deck: deck.clone(),
config_path,
layout_baseline: settings.workspace,
entries: Vec::new(),
next_number: 1,
close: CloseGuardState::default(),
close_dialog: None,
shutting_down: false,
}));
deck.connect_selected({
let state = Rc::downgrade(&state);
move |_| {
if let Some(state) = state.upgrade() {
sync_active(&state);
}
}
});
deck.connect_new_requested({
let state = Rc::downgrade(&state);
move || {
if let Some(state) = state.upgrade() {
add_session(&state);
}
}
});
deck.connect_activated({
let state = Rc::downgrade(&state);
move |id| {
if let Some(state) = state.upgrade() {
if let Some(ui) = session_ui(&state, id) {
ui.pane.focus();
}
}
}
});
deck.connect_close_requested({
let state = Rc::downgrade(&state);
move |id| {
if let Some(state) = state.upgrade() {
request_close(&state, id);
}
}
});
deck.connect_preferences_changed({
let weak = Rc::downgrade(&state);
move |_| {
if let Some(state) = weak.upgrade() {
persist_workspace_layout(&state);
}
}
});
rename.connect_clicked({
let state = Rc::downgrade(&state);
move |_| {
if let Some(state) = state.upgrade() {
rename_active(&state);
}
}
});
side.connect_clicked({
let deck = deck.clone();
let state = Rc::downgrade(&state);
move |button| {
let next = match deck.sidebar_side() {
SidebarSide::Left => SidebarSide::Right,
SidebarSide::Right => SidebarSide::Left,
};
deck.set_sidebar_side(next);
button.set_label(match next {
SidebarSide::Left => "Sidebar: Left",
SidebarSide::Right => "Sidebar: Right",
});
if let Some(state) = state.upgrade() {
persist_workspace_layout(&state);
if let Some(ui) = deck.active().and_then(|id| session_ui(&state, id)) {
ui.pane.focus();
}
}
}
});
install_shortcuts(&window, &state, &side);
install_workspace_close_guard(&window, &state);
insert_session(&state, launch, settings, spawn);
window.present();
sync_active(&state);
(window, state)
}
fn persist_workspace_layout(state: &Shared) {
let (path, baseline, layout, entries) = {
let workspace = state.borrow();
if workspace.shutting_down {
return;
}
let Some(active) = workspace
.entries
.iter()
.find(|entry| Some(entry.id) == workspace.deck.active())
else {
return;
};
let mut baseline = active.ui.settings.borrow().clone();
baseline.workspace = workspace.layout_baseline;
let layout = crate::config::WorkspaceSettings {
sidebar: workspace.deck.sidebar_side(),
preferences: workspace.deck.preferences(),
};
(
workspace.config_path.clone(),
baseline,
layout,
workspace
.entries
.iter()
.map(|entry| entry.ui.clone())
.collect::<Vec<_>>(),
)
};
let mut edited = baseline.clone();
edited.workspace = layout;
for ui in entries {
ui.settings.borrow_mut().workspace = layout;
}
if let Some(path) = path {
if let Err(error) = queue_config_persist(&path, &baseline, edited) {
report_action_error(Err(error));
return;
}
}
state.borrow_mut().layout_baseline = layout;
}
fn add_session(state: &Shared) {
let (ui, config_path) = {
let workspace = state.borrow();
if workspace.shutting_down || workspace.entries.len() >= lios_bar::MAX_SESSIONS {
return;
}
let Some(entry) = workspace
.entries
.iter()
.find(|entry| Some(entry.id) == workspace.deck.active())
else {
return;
};
(entry.ui.clone(), workspace.config_path.clone())
};
let launch = next_window_launch(
&ui.launch,
terminal_current_working_directory(ui.pane.terminal()),
);
let settings = next_window_settings(&latest_config_settings(
config_path.as_deref(),
&ui.settings.borrow(),
));
insert_session(state, launch, settings, true);
}
fn insert_session(
state: &Shared,
launch: LaunchConfig,
settings: AppSettings,
spawn: bool,
) -> Option<SessionId> {
let (window, deck, path, number) = {
let workspace = state.borrow();
if workspace.shutting_down || workspace.entries.len() >= lios_bar::MAX_SESSIONS {
return None;
}
(
workspace.window.upgrade()?,
workspace.deck.clone(),
workspace.config_path.clone(),
workspace.next_number,
)
};
let ui = build_session_ui(&window, launch.clone(), settings, path);
let name = format!("Terminal {number}");
let id = match deck.add(&ui.root, &name) {
Ok(id) => id,
Err(error) => {
eprintln!("could not create LiosBar session: {error}");
return None;
}
};
let new_session = gio::SimpleAction::new("new_session", None);
new_session.connect_activate({
let state = Rc::downgrade(state);
let context_menu = ui.context_menu.clone();
move |_, _| {
context_menu.popdown();
if let Some(state) = state.upgrade() {
add_session(&state);
}
}
});
ui.actions.add_action(&new_session);
ui.pane.terminal().connect_child_exited({
let state = Rc::downgrade(state);
move |_, status| {
let Some(state) = state.upgrade() else {
return;
};
let Some(ui) = session_ui(&state, id) else {
return;
};
ui.pane.mark_child_exited();
ui.pane.terminal().set_input_enabled(false);
let deck = state.borrow().deck.clone();
deck.set_exited(id, true);
deck.set_detail(id, &format!("Exited · status {status}"));
}
});
ui.pane.terminal().connect_window_title_changed({
let state = Rc::downgrade(state);
move |terminal| {
if let Some(state) = state.upgrade() {
let deck = state.borrow().deck.clone();
let title = terminal_window_title(terminal).unwrap_or_default();
deck.set_detail(id, &title);
}
}
});
{
let mut workspace = state.borrow_mut();
workspace.next_number = workspace.next_number.saturating_add(1);
workspace.entries.push(Entry {
id,
name,
ui: ui.clone(),
close_dialog: None,
});
}
sync_active(state); if spawn {
ui.pane.spawn(launch);
}
Some(id)
}
fn session_ui(state: &Shared, id: SessionId) -> Option<Rc<SessionUi>> {
state
.borrow()
.entries
.iter()
.find(|entry| entry.id == id)
.map(|entry| entry.ui.clone())
}
fn sync_active(state: &Shared) {
let (window, active, entries) = {
let workspace = state.borrow();
if workspace.shutting_down {
return;
}
let Some(window) = workspace.window.upgrade() else {
return;
};
(
window,
workspace.deck.active(),
workspace
.entries
.iter()
.map(|entry| (entry.id, entry.ui.clone()))
.collect::<Vec<_>>(),
)
};
for (id, ui) in entries {
let selected = Some(id) == active;
ui.active.set(selected);
ui.pane.set_active(selected && window.is_active());
if selected {
window.insert_action_group("term", Some(&ui.actions));
let fallback = sanitized_config_title(&ui.settings.borrow().window.title);
let title = terminal_window_title(ui.pane.terminal())
.map(|title| sanitized_terminal_title(&fallback, &title))
.unwrap_or(fallback);
window.set_title(Some(&title));
report_action_error(apply_live_app_settings(
&window,
&ui.pane,
&ui.settings.borrow(),
));
ui.pane.focus();
} else {
ui.context_menu.popdown();
ui.preferences.set_reveal_child(false);
close_search(&ui.search, ui.pane.terminal(), false);
}
}
}
fn request_close(state: &Shared, id: SessionId) {
let (ui, existing, window) = {
let workspace = state.borrow();
let Some(entry) = workspace.entries.iter().find(|entry| entry.id == id) else {
return;
};
(
entry.ui.clone(),
entry.close_dialog.as_ref().and_then(glib::WeakRef::upgrade),
workspace.window.upgrade(),
)
};
if let Some(dialog) = existing {
dialog.present();
return;
}
if !ui.pane.has_running_process() {
remove_session(state, id);
return;
}
let Some(window) = window else {
return;
};
let dialog = close_dialog(
&window,
"Close this session?",
"Its active command will stop and unsaved work may be lost.",
);
if let Some(entry) = state
.borrow_mut()
.entries
.iter_mut()
.find(|entry| entry.id == id)
{
entry.close_dialog = Some(dialog.downgrade());
}
dialog.connect_response({
let state = Rc::downgrade(state);
move |dialog, response| {
dialog.destroy();
let Some(state) = state.upgrade() else {
return;
};
if let Some(entry) = state
.borrow_mut()
.entries
.iter_mut()
.find(|entry| entry.id == id)
{
entry.close_dialog = None;
}
if response == gtk::ResponseType::Accept {
remove_session(&state, id);
}
}
});
dialog.present();
}
fn remove_session(state: &Shared, id: SessionId) {
let (entry, deck, window) = {
let mut workspace = state.borrow_mut();
let Some(index) = workspace.entries.iter().position(|entry| entry.id == id) else {
return;
};
(
workspace.entries.remove(index),
workspace.deck.clone(),
workspace.window.upgrade(),
)
};
entry.ui.active.set(false);
entry.ui.context_menu.popdown();
entry.ui.preferences.set_reveal_child(false);
close_search(&entry.ui.search, entry.ui.pane.terminal(), false);
entry.ui.pane.cancel_pending_spawn();
if let Some(dialog) = entry.close_dialog.and_then(|dialog| dialog.upgrade()) {
dialog.destroy();
}
deck.remove(id);
if deck.is_empty() {
if let Some(window) = window {
window.insert_action_group("term", None::<&gio::SimpleActionGroup>);
window.close();
}
}
}
fn rename_active(state: &Shared) {
let (window, id, name) = {
let workspace = state.borrow();
let Some(window) = workspace.window.upgrade() else {
return;
};
let Some(entry) = workspace
.entries
.iter()
.find(|entry| Some(entry.id) == workspace.deck.active())
else {
return;
};
(window, entry.id, entry.name.clone())
};
let dialog = gtk::Dialog::builder()
.title("Rename session")
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.build();
dialog.add_button("Cancel", gtk::ResponseType::Cancel);
dialog.add_button("Rename", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Accept);
let input = gtk::Entry::builder()
.text(&name)
.max_length(48)
.activates_default(true)
.margin_start(16)
.margin_end(16)
.margin_top(16)
.margin_bottom(16)
.build();
input.select_region(0, -1);
dialog.content_area().append(&input);
dialog.connect_response({
let state = Rc::downgrade(state);
move |dialog, response| {
if response == gtk::ResponseType::Accept {
if let Some(state) = state.upgrade() {
let deck = state.borrow().deck.clone();
if deck.rename(id, input.text().as_str()) {
if let Some(entry) = state
.borrow_mut()
.entries
.iter_mut()
.find(|entry| entry.id == id)
{
entry.name = input.text().to_string();
}
}
}
}
dialog.destroy();
}
});
dialog.present();
}
fn install_shortcuts(window: >k::ApplicationWindow, state: &Shared, side: >k::Button) {
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed({
let state = Rc::downgrade(state);
let side = side.downgrade();
move |_, key, _, modifiers| {
let Some(state) = state.upgrade() else {
return glib::Propagation::Proceed;
};
let modifiers = shortcut_modifiers(modifiers);
if modifiers == gdk::ModifierType::CONTROL_MASK {
let offset = match key {
gdk::Key::Page_Up => -1,
gdk::Key::Page_Down => 1,
_ => return glib::Propagation::Proceed,
};
let deck = state.borrow().deck.clone();
deck.select_relative(offset);
} else if modifiers == gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK {
if matches!(key, gdk::Key::Page_Up | gdk::Key::Page_Down) {
let deck = state.borrow().deck.clone();
deck.move_active(if key == gdk::Key::Page_Up { -1 } else { 1 });
return glib::Propagation::Stop;
}
match key.to_unicode().map(|c| c.to_ascii_lowercase()) {
Some('t') => add_session(&state),
Some('w') => {
let id = state.borrow().deck.active();
if let Some(id) = id {
request_close(&state, id);
}
}
Some('r') => rename_active(&state),
Some('l') => {
let deck = state.borrow().deck.clone();
deck.focus_search();
}
Some('b') => {
if let Some(side) = side.upgrade() {
side.emit_clicked();
}
}
_ => return glib::Propagation::Proceed,
}
} else {
return glib::Propagation::Proceed;
}
glib::Propagation::Stop
}
});
window.add_controller(keys);
}
fn close_dialog(window: >k::ApplicationWindow, title: &str, detail: &str) -> gtk::MessageDialog {
let dialog = gtk::MessageDialog::builder()
.transient_for(window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::None)
.text(title)
.secondary_text(detail)
.build();
dialog.add_button("Keep Running", gtk::ResponseType::Cancel);
dialog.add_button("Close", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
dialog
}
fn install_workspace_close_guard(window: >k::ApplicationWindow, state: &Shared) {
window.connect_unrealize({
let state = Rc::downgrade(state);
move |_| {
if let Some(state) = state.upgrade() {
dispose(&state);
}
}
});
let state = state.clone();
window.connect_close_request(move |window| {
let panes = state.borrow().entries.iter().map(|entry| entry.ui.pane.clone()).collect::<Vec<_>>();
let busy = panes.iter().filter(|pane| pane.has_running_process()).count();
let decision = state.borrow_mut().close.request(busy != 0);
match decision {
CloseDecision::Allow => { dispose(&state); glib::Propagation::Proceed }
CloseDecision::WaitForDialog => {
let dialog = state.borrow().close_dialog.as_ref().and_then(glib::WeakRef::upgrade);
if let Some(dialog) = dialog { dialog.present(); }
glib::Propagation::Stop
}
CloseDecision::Prompt => {
let dialog = close_dialog(window, "Close this LiosBar workspace?", &format!("{busy} session(s) have active commands. Closing stops all sessions and may lose unsaved work."));
state.borrow_mut().close_dialog = Some(dialog.downgrade());
dialog.connect_response({
let state = Rc::downgrade(&state);
let window = window.downgrade();
move |dialog, response| {
dialog.destroy();
let Some(state) = state.upgrade() else { return; };
state.borrow_mut().close_dialog = None;
if response == gtk::ResponseType::Accept {
state.borrow_mut().close.confirm();
if let Some(window) = window.upgrade() { window.close(); }
} else { state.borrow_mut().close.cancel(); }
}
});
dialog.present();
glib::Propagation::Stop
}
}
});
}
fn dispose(state: &Shared) {
let (entries, deck, window) = {
let mut workspace = state.borrow_mut();
if workspace.shutting_down {
return;
}
workspace.shutting_down = true;
(
std::mem::take(&mut workspace.entries),
workspace.deck.clone(),
workspace.window.upgrade(),
)
};
if let Some(window) = window {
window.insert_action_group("term", None::<&gio::SimpleActionGroup>);
}
for entry in entries {
entry.ui.active.set(false);
entry.ui.context_menu.popdown();
entry.ui.preferences.set_reveal_child(false);
close_search(&entry.ui.search, entry.ui.pane.terminal(), false);
entry.ui.pane.cancel_pending_spawn();
if let Some(dialog) = entry.close_dialog.and_then(|dialog| dialog.upgrade()) {
dialog.destroy();
}
deck.remove(entry.id);
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
pub(in crate::app) fn exercise(
app: >k::Application,
settings: AppSettings,
screenshot: impl Fn(>k::ApplicationWindow, &str),
) {
let launch = LaunchConfig {
command: LaunchCommand::DefaultShell,
working_directory: None,
};
let original_background = settings.terminal.background.image.clone();
let original_prompt = settings.terminal.prompt.clone();
assert_eq!(std::env::var("LIOS_GUI_TEST_CHILD").as_deref(), Ok("1"));
let layout_path = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap())
.join("workspace-layout.toml");
settings.persist(&layout_path).unwrap();
let (window, state) = build_workspace(
app,
launch.clone(),
settings.clone(),
Some(layout_path.clone()),
false,
);
let first = state.borrow().deck.active().unwrap();
let first_ui = session_ui(&state, first).unwrap();
let second = insert_session(&state, launch, settings.clone(), false).unwrap();
let second_ui = session_ui(&state, second).unwrap();
assert!(!first_ui.active.get());
assert!(second_ui.active.get());
assert_eq!(
second_ui.settings.borrow().terminal.background.image,
original_background
);
assert_eq!(second_ui.settings.borrow().terminal.prompt, original_prompt);
first_ui.pane.terminal().feed(b"FIRST SESSION\r\n");
second_ui.pane.terminal().feed(b"SECOND SESSION\r\n");
gtk::prelude::WidgetExt::activate_action(&second_ui.root, "term.find", None).unwrap();
assert!(second_ui.search.revealer.reveals_child());
assert!(!first_ui.search.revealer.reveals_child());
assert_ne!(
first_ui.actions.lookup_action("paste"),
second_ui.actions.lookup_action("paste")
);
assert_ne!(
first_ui.actions.lookup_action("find"),
second_ui.actions.lookup_action("find")
);
let deck = state.borrow().deck.clone();
deck.select(first);
assert!(!second_ui.search.revealer.reveals_child());
gtk::prelude::WidgetExt::activate_action(&first_ui.root, "term.find", None).unwrap();
assert!(first_ui.search.revealer.reveals_child());
assert!(!second_ui.search.revealer.reveals_child());
assert!(first_ui.active.get());
assert!(!second_ui.active.get());
deck.set_sidebar_side(SidebarSide::Right);
assert_eq!(deck.active(), Some(first));
assert_eq!(deck.len(), 2);
deck.set_sidebar_side(SidebarSide::Left);
spin_for(Duration::from_millis(150));
crate::paste::PasteController::exercise_session_switch(
&window,
first_ui.pane.terminal(),
|| {
deck.select(second);
},
|| {
deck.select(first);
},
);
first_ui.pane.terminal().feed(b"\x1b]2;Active title\x07");
second_ui.pane.terminal().feed(b"\x1b]2;Hidden title\x07");
spin_for(Duration::from_millis(150));
assert!(window.title().unwrap().contains("Active title"));
assert!(!window.title().unwrap().contains("Hidden title"));
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !first_ui.pane.has_resolved_background_image() && std::time::Instant::now() < deadline
{
spin_for(Duration::from_millis(20));
}
assert!(
first_ui.pane.has_resolved_background_image(),
"the original bundled wallpaper is rendered inside the workspace"
);
spin_for(Duration::from_millis(150));
screenshot(&window, "liosbar-integrated.png");
let layout = super::super::tests::named_widget(deck.widget(), "liosbar-layout-menu")
.unwrap()
.downcast::<gtk::MenuButton>()
.unwrap();
layout.popup();
spin_for(Duration::from_millis(100));
for (name, enabled) in [
("liosbar-layout-compact", true),
("liosbar-layout-titles", false),
("liosbar-layout-hints", false),
] {
super::super::tests::named_widget(deck.widget(), name)
.unwrap()
.downcast::<gtk::CheckButton>()
.unwrap()
.set_active(enabled);
}
let compact = lios_bar::SidebarPreferences {
compact: true,
show_titles: false,
show_hints: false,
};
assert_eq!(deck.preferences(), compact);
assert_eq!(deck.active(), Some(first));
for ui in [&first_ui, &second_ui] {
assert_eq!(ui.settings.borrow().workspace.preferences, compact);
assert_eq!(
ui.settings.borrow().terminal.background.image,
original_background
);
assert_eq!(ui.settings.borrow().terminal.prompt, original_prompt);
}
spin_for(Duration::from_millis(150));
let layout_popover = layout.popover().unwrap();
assert!(layout_popover.is_visible());
super::super::tests::save_widget_screenshot(
layout_popover.upcast_ref(),
"liosbar-layout-menu.png",
);
layout.popdown();
let side = super::super::tests::named_widget(&window, "liosbar-sidebar-side")
.unwrap()
.downcast::<gtk::Button>()
.unwrap();
side.emit_clicked();
let saved_layout = crate::config::WorkspaceSettings {
sidebar: SidebarSide::Right,
preferences: compact,
};
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while AppSettings::load(Some(layout_path.clone()), Default::default())
.unwrap()
.workspace
!= saved_layout
&& std::time::Instant::now() < deadline
{
spin_for(Duration::from_millis(20));
}
let saved = AppSettings::load(Some(layout_path.clone()), Default::default()).unwrap();
assert_eq!(
saved.workspace, saved_layout,
"sidebar edits are saved through the shared config writer"
);
assert_eq!(saved.terminal.background.image, original_background);
assert_eq!(saved.terminal.font, settings.terminal.font);
screenshot(&window, "liosbar-compact-right.png");
let (restored_window, restored_state) = build_workspace(
app,
LaunchConfig {
command: LaunchCommand::DefaultShell,
working_directory: None,
},
saved,
None,
false,
);
assert_eq!(restored_state.borrow().deck.preferences(), compact);
assert_eq!(
restored_state.borrow().deck.sidebar_side(),
SidebarSide::Right
);
restored_window.destroy();
first_ui.settings.borrow_mut().workspace = Default::default();
super::super::tests::named_widget(deck.widget(), "liosbar-layout-reset")
.unwrap()
.downcast::<gtk::Button>()
.unwrap()
.emit_clicked();
assert_eq!(deck.preferences(), Default::default());
assert_eq!(deck.active(), Some(first));
assert_eq!(deck.sidebar_side(), SidebarSide::Right);
side.emit_clicked();
assert_eq!(deck.sidebar_side(), SidebarSide::Left);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while AppSettings::load(Some(layout_path.clone()), Default::default())
.unwrap()
.workspace
!= Default::default()
&& std::time::Instant::now() < deadline
{
spin_for(Duration::from_millis(20));
}
assert_eq!(
AppSettings::load(Some(layout_path.clone()), Default::default())
.unwrap()
.workspace,
Default::default(),
"late session snapshots cannot drop a layout reset"
);
first_ui.pane.focus();
let original_focus = GtkWindowExt::focus(&window);
let controllers = window.observe_controllers();
let reorder_keys = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.find_map(|controller| controller.downcast::<gtk::EventControllerKey>().ok())
.unwrap();
let modifiers = gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK;
let press = |key, modifiers| {
reorder_keys.emit_by_name::<bool>("key-pressed", &[&key, &0_u32, &modifiers])
};
assert!(press(gdk::Key::Page_Down, modifiers));
assert_eq!(deck.session_ids(), vec![second, first]);
assert_eq!(deck.active(), Some(first));
assert!(first_ui.active.get());
assert!(!second_ui.active.get());
assert_eq!(GtkWindowExt::focus(&window), original_focus);
assert!(!press(
gdk::Key::Page_Up,
modifiers | gdk::ModifierType::ALT_MASK
));
assert_eq!(deck.session_ids(), vec![second, first]);
spin_for(Duration::from_millis(150));
screenshot(&window, "liosbar-reordered.png");
assert!(press(gdk::Key::Page_Up, modifiers));
assert_eq!(deck.session_ids(), vec![first, second]);
deck.focus_search();
let filter = super::super::tests::named_widget(deck.widget(), "liosbar-session-filter")
.unwrap()
.downcast::<gtk::Entry>()
.unwrap();
let controllers = filter.observe_controllers();
let keys = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.filter_map(|controller| controller.downcast::<gtk::EventControllerKey>().ok())
.find(|controller| controller.propagation_phase() == gtk::PropagationPhase::Capture)
.unwrap();
filter.set_text("Terminal");
assert!(keys.emit_by_name::<bool>(
"key-pressed",
&[&gdk::Key::Down, &0_u32, &gdk::ModifierType::empty()]
));
assert_eq!(deck.active(), Some(first));
assert!(first_ui.active.get());
assert!(!second_ui.active.get());
assert!(GtkWindowExt::focus(&window).is_some_and(|focus| focus.is_ancestor(&filter)));
spin_for(Duration::from_millis(100));
screenshot(&window, "liosbar-switcher-preview.png");
filter.emit_activate();
assert_eq!(deck.active(), Some(second));
assert!(second_ui.active.get());
assert_eq!(
GtkWindowExt::focus(&window),
Some(second_ui.pane.terminal().clone().upcast()),
"Enter must route the window's keyboard focus to the chosen terminal"
);
gtk::prelude::WidgetExt::activate_action(&window, "term.find", None).unwrap();
assert!(second_ui.search.revealer.reveals_child());
assert!(!first_ui.search.revealer.reveals_child());
deck.focus_search();
assert!(keys.emit_by_name::<bool>(
"key-pressed",
&[&gdk::Key::Escape, &0_u32, &gdk::ModifierType::empty()]
));
assert_eq!(deck.active(), Some(second));
assert_eq!(
GtkWindowExt::focus(&window),
Some(second_ui.pane.terminal().clone().upcast()),
"Escape must return the window's keyboard focus to the same terminal"
);
assert!(filter.text().is_empty());
deck.select(first);
second_ui
.pane
.terminal()
.emit_by_name::<()>("child-exited", &[&0_i32]);
assert!(window.is_visible());
assert_eq!(
deck.len(),
2,
"an exiting background child cannot close the workspace"
);
assert!(!second_ui.pane.terminal().is_input_enabled());
remove_session(&state, second);
assert_eq!(deck.active(), Some(first));
assert!(first_ui.active.get());
let busy = insert_session(
&state,
LaunchConfig {
command: LaunchCommand::Argv(vec!["/bin/sleep".into(), "2".into()]),
working_directory: None,
},
settings.clone(),
true,
)
.unwrap();
deck.select(first);
assert!(session_ui(&state, busy).unwrap().pane.has_running_process());
window.close();
let dialog = state
.borrow()
.close_dialog
.as_ref()
.unwrap()
.upgrade()
.unwrap();
window.close();
assert_eq!(
state.borrow().close_dialog.as_ref().unwrap().upgrade(),
Some(dialog.clone())
);
dialog.response(gtk::ResponseType::Cancel);
assert!(window.is_visible());
assert_eq!(deck.len(), 2);
request_close(&state, busy);
let dialog = state
.borrow()
.entries
.iter()
.find(|entry| entry.id == busy)
.unwrap()
.close_dialog
.as_ref()
.unwrap()
.upgrade()
.unwrap();
request_close(&state, busy);
dialog.response(gtk::ResponseType::Cancel);
assert!(session_ui(&state, busy).is_some());
request_close(&state, busy);
let dialog = state
.borrow()
.entries
.iter()
.find(|entry| entry.id == busy)
.unwrap()
.close_dialog
.as_ref()
.unwrap()
.upgrade()
.unwrap();
dialog.response(gtk::ResponseType::Accept);
assert!(session_ui(&state, busy).is_none());
assert_eq!(deck.active(), Some(first));
let child = insert_session(
&state,
LaunchConfig {
command: LaunchCommand::Argv(vec![
"/bin/sh".into(),
"-c".into(),
"printf LIOS_CHILD_READY".into(),
]),
working_directory: None,
},
settings,
true,
)
.unwrap();
let child_ui = session_ui(&state, child).unwrap();
deck.select(first);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while child_ui.pane.terminal().is_input_enabled() && std::time::Instant::now() < deadline {
spin_for(Duration::from_millis(20));
}
assert!(
!child_ui.pane.terminal().is_input_enabled(),
"the child exited within the deadline"
);
child_ui.pane.terminal().select_all();
assert!(
child_ui
.pane
.terminal()
.text_selected(vte::Format::Text)
.unwrap()
.contains("LIOS_CHILD_READY")
);
assert_eq!(deck.active(), Some(first));
assert!(window.is_visible());
remove_session(&state, child);
window.destroy();
assert!(state.borrow().entries.is_empty());
}
fn spin_for(duration: Duration) {
let main_loop = glib::MainLoop::new(None, false);
glib::timeout_add_local_once(duration, {
let main_loop = main_loop.clone();
move || main_loop.quit()
});
main_loop.run();
}
}