use gtk::pango;
use gtk::prelude::*;
use std::cell::{Cell, RefCell};
use std::fmt;
use std::rc::Rc;
pub const MAX_SESSIONS: usize = 32;
const MAX_NAME_CHARS: usize = 48;
const MAX_DETAIL_CHARS: usize = 96;
const SIDEBAR_WIDTH: i32 = 220;
const MAX_LABEL_INPUT_CHARS: usize = 4096;
const MAX_FILTER_CHARS: i32 = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionId(u64);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SidebarSide {
#[default]
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SidebarPreferences {
pub compact: bool,
pub show_titles: bool,
pub show_hints: bool,
}
impl Default for SidebarPreferences {
fn default() -> Self {
Self {
compact: false,
show_titles: true,
show_hints: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddError {
SessionLimitReached,
AlreadyParented,
InvalidContent,
IdExhausted,
}
impl fmt::Display for AddError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::SessionLimitReached => "the workspace already contains 32 sessions",
Self::AlreadyParented => "the content widget already has a parent",
Self::InvalidContent => {
"content cannot be a root widget, the workspace, or its ancestor"
}
Self::IdExhausted => "the workspace has exhausted its session identifiers",
})
}
}
impl std::error::Error for AddError {}
type SelectedCallback = Rc<dyn Fn(Option<SessionId>)>;
type NewCallback = Rc<dyn Fn()>;
type CloseCallback = Rc<dyn Fn(SessionId)>;
type ActivatedCallback = Rc<dyn Fn(SessionId)>;
type PreferencesCallback = Rc<dyn Fn(SidebarPreferences)>;
struct Session {
id: SessionId,
content: gtk::Widget,
row: gtk::ListBoxRow,
name: gtk::Label,
detail: gtk::Label,
}
struct Model {
sessions: Vec<Session>,
active: Option<SessionId>,
next_id: u64,
}
struct Inner {
paned: gtk::Paned,
sidebar: gtk::Box,
stack: gtk::Stack,
list: gtk::ListBox,
scroll: gtk::ScrolledWindow,
search: gtk::Entry,
search_focused: Cell<bool>,
preview: Cell<Option<SessionId>>,
empty: gtk::Label,
count: gtk::Label,
hint: gtk::Label,
layout: gtk::MenuButton,
compact: gtk::CheckButton,
show_titles: gtk::CheckButton,
show_hints: gtk::CheckButton,
preferences: Cell<SidebarPreferences>,
changing_preferences: Cell<bool>,
reveal_pending: Cell<bool>,
reveal_after_layout: Cell<bool>,
close: gtk::Button,
new: gtk::Button,
side: Cell<SidebarSide>,
sidebar_width: Cell<i32>,
divider_width: Cell<i32>,
position_pending: Cell<bool>,
model: RefCell<Model>,
changing_rows: Cell<bool>,
selected_callbacks: RefCell<Vec<SelectedCallback>>,
new_callbacks: RefCell<Vec<NewCallback>>,
close_callbacks: RefCell<Vec<CloseCallback>>,
activated_callbacks: RefCell<Vec<ActivatedCallback>>,
preferences_callbacks: RefCell<Vec<PreferencesCallback>>,
}
#[derive(Clone)]
pub struct SessionWorkspace {
inner: Rc<Inner>,
}
impl SessionWorkspace {
pub fn new(side: SidebarSide) -> Self {
let paned = gtk::Paned::new(gtk::Orientation::Horizontal);
paned.set_hexpand(true);
paned.set_vexpand(true);
paned.set_wide_handle(false);
paned.add_css_class("liosbar-workspace");
let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 8);
sidebar.add_css_class("liosbar-sidebar");
sidebar.set_size_request(190, -1);
let heading = gtk::Box::new(gtk::Orientation::Horizontal, 6);
heading.set_margin_start(12);
heading.set_margin_end(10);
heading.set_margin_top(8);
let title = gtk::Label::new(Some("SESSIONS"));
title.add_css_class("liosbar-section-title");
title.set_halign(gtk::Align::Start);
title.set_hexpand(true);
heading.append(&title);
let layout = gtk::MenuButton::builder()
.icon_name("emblem-system-symbolic")
.tooltip_text("Sidebar layout")
.build();
layout.set_widget_name("liosbar-layout-menu");
layout.update_property(&[gtk::accessible::Property::Label("Sidebar layout")]);
let options = gtk::Box::new(gtk::Orientation::Vertical, 10);
options.set_margin_start(12);
options.set_margin_end(12);
options.set_margin_top(12);
options.set_margin_bottom(12);
let options_title = gtk::Label::new(Some("Sidebar layout"));
options_title.add_css_class("liosbar-section-title");
options_title.set_halign(gtk::Align::Start);
options.append(&options_title);
let compact = gtk::CheckButton::with_label("Compact rows");
compact.set_widget_name("liosbar-layout-compact");
let show_titles = gtk::CheckButton::with_label("Show shell titles");
show_titles.set_tooltip_text(Some(
"Hide running-session titles while keeping exit details visible",
));
show_titles.set_widget_name("liosbar-layout-titles");
show_titles.set_active(true);
let show_hints = gtk::CheckButton::with_label("Show keyboard hints");
show_hints.set_widget_name("liosbar-layout-hints");
show_hints.set_active(true);
options.append(&compact);
options.append(&show_titles);
options.append(&show_hints);
let reset = gtk::Button::with_label("Reset layout");
reset.set_widget_name("liosbar-layout-reset");
reset.set_tooltip_text(Some("Restore sidebar display choices; keep sessions, sidebar position, and terminal settings"));
options.append(&reset);
let popover = gtk::Popover::new();
popover.add_css_class("liosbar-layout-popover");
popover.set_autohide(true);
popover.set_child(Some(&options));
layout.set_popover(Some(&popover));
heading.append(&layout);
sidebar.append(&heading);
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
controls.set_margin_start(10);
controls.set_margin_end(10);
let new = gtk::Button::with_label("+ New");
new.set_tooltip_text(Some("Request a new terminal session"));
new.add_css_class("suggested-action");
new.set_hexpand(true);
let close = gtk::Button::with_label("Close");
close.set_tooltip_text(Some("Request closing the selected session"));
close.set_sensitive(false);
controls.append(&new);
controls.append(&close);
sidebar.append(&controls);
let search = gtk::Entry::builder()
.placeholder_text("Filter sessions…")
.max_length(MAX_FILTER_CHARS)
.width_chars(8)
.max_width_chars(16)
.primary_icon_name("system-search-symbolic")
.secondary_icon_name("edit-clear-symbolic")
.margin_start(10)
.margin_end(10)
.build();
search.add_css_class("liosbar-session-filter");
search.set_widget_name("liosbar-session-filter");
search.set_tooltip_text(Some("Find a session by name or title; ↑/↓ choose, Enter switches, Escape returns to the current terminal"));
search.update_property(&[gtk::accessible::Property::Label("Filter terminal sessions")]);
sidebar.append(&search);
let list = gtk::ListBox::new();
list.add_css_class("liosbar-session-list");
list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(true);
let empty = gtk::Label::new(Some("No matching sessions"));
empty.add_css_class("liosbar-empty");
empty.set_wrap(true);
empty.set_margin_top(16);
empty.set_margin_bottom(16);
list.set_placeholder(Some(&empty));
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.child(&list)
.vexpand(true)
.build();
sidebar.append(&scroll);
let count = gtk::Label::new(Some("0 / 32 sessions"));
count.add_css_class("liosbar-footer");
count.set_margin_bottom(10);
sidebar.append(&count);
let hint = gtk::Label::new(Some("↑↓ choose · Enter switch · Esc cancel"));
hint.add_css_class("liosbar-footer");
hint.set_wrap(true);
hint.set_margin_start(10);
hint.set_margin_end(10);
hint.set_margin_bottom(10);
sidebar.append(&hint);
search.update_relation(&[
gtk::accessible::Relation::Controls(&[list.upcast_ref()]),
gtk::accessible::Relation::DescribedBy(&[hint.upcast_ref()]),
]);
let stack = gtk::Stack::new();
stack.add_css_class("liosbar-terminal-stack");
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.set_transition_type(gtk::StackTransitionType::None);
stack.set_hhomogeneous(false);
stack.set_vhomogeneous(false);
let inner = Rc::new(Inner {
paned,
sidebar,
stack,
list,
scroll,
search,
search_focused: Cell::new(false),
preview: Cell::new(None),
empty,
count,
hint,
layout,
compact,
show_titles,
show_hints,
preferences: Cell::new(SidebarPreferences::default()),
changing_preferences: Cell::new(false),
reveal_pending: Cell::new(false),
reveal_after_layout: Cell::new(false),
close,
new,
side: Cell::new(side),
sidebar_width: Cell::new(SIDEBAR_WIDTH),
divider_width: Cell::new(1),
position_pending: Cell::new(true),
model: RefCell::new(Model {
sessions: Vec::new(),
active: None,
next_id: 1,
}),
changing_rows: Cell::new(false),
selected_callbacks: RefCell::new(Vec::new()),
new_callbacks: RefCell::new(Vec::new()),
close_callbacks: RefCell::new(Vec::new()),
activated_callbacks: RefCell::new(Vec::new()),
preferences_callbacks: RefCell::new(Vec::new()),
});
arrange_sidebar(&inner);
for check in [&inner.compact, &inner.show_titles, &inner.show_hints] {
check.connect_toggled({
let weak = Rc::downgrade(&inner);
move |_| {
let Some(inner) = weak.upgrade() else {
return;
};
if inner.changing_preferences.get() {
return;
}
let preferences = SidebarPreferences {
compact: inner.compact.is_active(),
show_titles: inner.show_titles.is_active(),
show_hints: inner.show_hints.is_active(),
};
SessionWorkspace { inner }.set_preferences(preferences);
}
});
}
reset.connect_clicked({
let weak = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = weak.upgrade() {
SessionWorkspace { inner }.set_preferences(SidebarPreferences::default());
}
}
});
inner.list.connect_row_selected({
let inner = Rc::downgrade(&inner);
move |_, row| {
let Some(inner) = inner.upgrade() else {
return;
};
if inner.changing_rows.get() {
return;
}
let selected = {
let model = inner.model.borrow();
row.and_then(|row| {
model
.sessions
.iter()
.find(|session| session.row == *row)
.map(|session| session.id)
})
};
if let Some(id) = selected {
SessionWorkspace { inner }.select(id);
}
}
});
inner.list.connect_row_activated({
let inner = Rc::downgrade(&inner);
move |_, row| {
let Some(inner) = inner.upgrade() else {
return;
};
let id = inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.row == *row)
.map(|session| session.id);
if let Some(id) = id {
let workspace = SessionWorkspace {
inner: inner.clone(),
};
workspace.select(id);
emit_activated(&inner, id);
}
}
});
inner.search.connect_changed({
let inner = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = inner.upgrade() {
inner.preview.set(None);
update_filter(&inner);
if inner.search_focused.get() {
reveal_selection(&inner);
}
}
}
});
inner.search.connect_icon_press(|entry, position| {
if position == gtk::EntryIconPosition::Secondary {
entry.set_text("");
}
});
inner.search.connect_activate({
let inner = Rc::downgrade(&inner);
move |_| {
let Some(inner) = inner.upgrade() else {
return;
};
let visible = visible_sessions(&inner);
let id = inner
.preview
.get()
.filter(|id| visible.contains(id))
.or_else(|| visible.first().copied());
if let Some(id) = id {
let workspace = SessionWorkspace {
inner: inner.clone(),
};
workspace.select(id);
emit_activated(&inner, id);
}
}
});
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed({
let inner = Rc::downgrade(&inner);
move |_, key, _, modifiers| {
inner
.upgrade()
.map_or(gtk::glib::Propagation::Proceed, |inner| {
filter_key(&inner, key, modifiers)
})
}
});
inner.search.add_controller(keys);
let focus = gtk::EventControllerFocus::new();
focus.connect_enter({
let inner = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = inner.upgrade() {
inner.search_focused.set(true);
let preferred = inner
.search
.text()
.trim()
.is_empty()
.then(|| inner.model.borrow().active)
.flatten();
reconcile_preview(&inner, preferred);
reveal_selection(&inner);
}
}
});
focus.connect_leave({
let inner = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = inner.upgrade() {
inner.search_focused.set(false);
set_preview(&inner, None);
}
}
});
inner.search.add_controller(focus);
inner.new.connect_clicked({
let inner = Rc::downgrade(&inner);
move |_| {
let Some(inner) = inner.upgrade() else {
return;
};
if inner.model.borrow().sessions.len() >= MAX_SESSIONS {
return;
}
let callbacks = inner.new_callbacks.borrow().clone();
for callback in callbacks {
callback();
}
}
});
inner.close.connect_clicked({
let inner = Rc::downgrade(&inner);
move |_| {
let Some(inner) = inner.upgrade() else {
return;
};
let Some(id) = inner.model.borrow().active else {
return;
};
let callbacks = inner.close_callbacks.borrow().clone();
for callback in callbacks {
callback(id);
}
}
});
inner.paned.connect_map({
let inner = Rc::downgrade(&inner);
move |paned| {
let Some(inner) = inner.upgrade() else {
return;
};
if !inner.position_pending.get() {
return;
}
let inner = Rc::downgrade(&inner);
paned.add_tick_callback(move |_, _| {
let Some(inner) = inner.upgrade() else {
return gtk::glib::ControlFlow::Break;
};
if position_sidebar(&inner) {
gtk::glib::ControlFlow::Break
} else {
gtk::glib::ControlFlow::Continue
}
});
}
});
inner.paned.connect_unmap({
let inner = Rc::downgrade(&inner);
move |_| {
if let Some(inner) = inner.upgrade() {
remember_sidebar_width(&inner);
inner.position_pending.set(true);
}
}
});
Self { inner }
}
pub fn widget(&self) -> >k::Paned {
&self.inner.paned
}
pub fn add(&self, content: &impl IsA<gtk::Widget>, name: &str) -> Result<SessionId, AddError> {
let content = content.as_ref();
if content.is::<gtk::Root>()
|| content == self.inner.paned.upcast_ref::<gtk::Widget>()
|| self.inner.paned.is_ancestor(content)
{
return Err(AddError::InvalidContent);
}
if content.parent().is_some() {
return Err(AddError::AlreadyParented);
}
let (id, next_id) = {
let model = self.inner.model.borrow();
if model.sessions.len() >= MAX_SESSIONS {
return Err(AddError::SessionLimitReached);
}
(
SessionId(model.next_id),
model.next_id.checked_add(1).ok_or(AddError::IdExhausted)?,
)
};
let name =
normalized_text(name, MAX_NAME_CHARS).unwrap_or_else(|| format!("Terminal {}", id.0));
let (row, name_label, detail) = session_row(&name);
apply_row_preferences(&row, &detail, self.inner.preferences.get());
install_row_drag(&self.inner, &row, id);
{
let mut model = self.inner.model.borrow_mut();
model.next_id = next_id;
model.sessions.push(Session {
id,
content: content.clone(),
row: row.clone(),
name: name_label,
detail,
});
}
{
let _guard = RowUpdate::new(&self.inner.changing_rows);
self.inner.stack.add_named(content, Some(&id.0.to_string()));
self.inner.list.append(&row);
}
update_controls(&self.inner);
update_filter(&self.inner);
self.select(id);
Ok(id)
}
pub fn active(&self) -> Option<SessionId> {
self.inner.model.borrow().active
}
pub fn session_ids(&self) -> Vec<SessionId> {
self.inner
.model
.borrow()
.sessions
.iter()
.map(|session| session.id)
.collect()
}
pub fn reorder(&self, id: SessionId, position: usize) -> bool {
let row = {
let mut model = self.inner.model.borrow_mut();
let Some(current) = model.sessions.iter().position(|session| session.id == id) else {
return false;
};
if position >= model.sessions.len() || position == current {
return false;
}
let session = model.sessions.remove(current);
let row = session.row.clone();
model.sessions.insert(position, session);
row
};
let had_focus = row.has_focus();
{
let _guard = RowUpdate::new(&self.inner.changing_rows);
self.inner.list.remove(&row);
self.inner.list.insert(&row, position as i32);
update_filter(&self.inner);
if had_focus {
row.grab_focus();
}
}
if Some(id) == self.active() || Some(id) == self.inner.preview.get() {
reveal_selection(&self.inner);
}
true
}
pub fn move_active(&self, offset: isize) -> bool {
let ids = self.session_ids();
let target = ids
.iter()
.position(|id| Some(*id) == self.active())
.map(|index| {
(
ids[index],
index.saturating_add_signed(offset).min(ids.len() - 1),
)
});
target.is_some_and(|(id, index)| self.reorder(id, index))
}
pub fn select(&self, id: SessionId) -> bool {
let (content, row, changed) = {
let mut model = self.inner.model.borrow_mut();
let Some(session) = model.sessions.iter().find(|session| session.id == id) else {
return false;
};
let content = session.content.clone();
let row = session.row.clone();
let changed = model.active != Some(id);
model.active = Some(id);
(content, row, changed)
};
if !row.is_visible() {
self.inner.search.set_text("");
}
set_preview(&self.inner, None);
{
let _guard = RowUpdate::new(&self.inner.changing_rows);
self.inner.stack.set_visible_child(&content);
self.inner.list.select_row(Some(&row));
}
update_controls(&self.inner);
reveal_selection(&self.inner);
if changed {
emit_selected(&self.inner, Some(id));
}
true
}
pub fn select_relative(&self, offset: isize) {
let next = {
let model = self.inner.model.borrow();
if model.sessions.is_empty() {
return;
}
let current = model
.sessions
.iter()
.position(|session| Some(session.id) == model.active)
.unwrap_or(0);
let index = relative_index(current, offset, model.sessions.len());
model.sessions[index].id
};
self.select(next);
}
pub fn remove(&self, id: SessionId) -> bool {
let (removed, selection, previous) = {
let mut model = self.inner.model.borrow_mut();
let Some(index) = model.sessions.iter().position(|session| session.id == id) else {
return false;
};
let removed = model.sessions.remove(index);
let previous = model.active;
if model.active == Some(id) {
model.active = model
.sessions
.get(index.min(model.sessions.len().saturating_sub(1)))
.map(|session| session.id);
}
let selection = model.active.and_then(|active| {
model
.sessions
.iter()
.find(|session| session.id == active)
.map(|session| (active, session.content.clone(), session.row.clone()))
});
(removed, selection, previous)
};
{
let _guard = RowUpdate::new(&self.inner.changing_rows);
self.inner.stack.remove(&removed.content);
self.inner.list.remove(&removed.row);
if let Some((_, content, row)) = &selection {
self.inner.stack.set_visible_child(content);
self.inner.list.select_row(Some(row));
}
}
update_controls(&self.inner);
let next = selection.as_ref().map(|(id, _, _)| *id);
if previous != next
&& selection
.as_ref()
.is_some_and(|(_, _, row)| !row.is_visible())
{
self.inner.search.set_text("");
}
update_filter(&self.inner);
if previous != next {
reveal_selection(&self.inner);
emit_selected(&self.inner, next);
}
true
}
pub fn rename(&self, id: SessionId, name: &str) -> bool {
let Some(name) = normalized_text(name, MAX_NAME_CHARS) else {
return false;
};
let label = self
.inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.id == id)
.map(|session| session.name.clone());
if let Some(label) = label {
label.set_label(&name);
label.set_tooltip_text(Some(&name));
update_filter(&self.inner);
return true;
}
false
}
pub fn set_detail(&self, id: SessionId, detail: &str) -> bool {
let widgets = self
.inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.id == id)
.map(|session| (session.row.clone(), session.detail.clone()));
if let Some((row, label)) = widgets {
let detail = normalized_text(detail, MAX_DETAIL_CHARS).unwrap_or_default();
label.set_label(&detail);
apply_row_preferences(&row, &label, self.inner.preferences.get());
label.set_tooltip_text((!detail.is_empty()).then_some(detail.as_str()));
update_filter(&self.inner);
return true;
}
false
}
pub fn set_exited(&self, id: SessionId, exited: bool) -> bool {
let widgets = self
.inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.id == id)
.map(|session| (session.row.clone(), session.detail.clone()));
if let Some((row, detail)) = widgets {
if exited {
row.add_css_class("liosbar-session-exited");
} else {
row.remove_css_class("liosbar-session-exited");
}
apply_row_preferences(&row, &detail, self.inner.preferences.get());
return true;
}
false
}
pub fn set_sidebar_side(&self, side: SidebarSide) {
if self.inner.side.get() != side {
remember_sidebar_width(&self.inner);
self.inner.side.set(side);
arrange_sidebar(&self.inner);
}
}
pub fn sidebar_side(&self) -> SidebarSide {
self.inner.side.get()
}
pub fn preferences(&self) -> SidebarPreferences {
self.inner.preferences.get()
}
pub fn set_preferences(&self, preferences: SidebarPreferences) {
if self.inner.preferences.replace(preferences) == preferences {
return;
}
{
let _guard = RowUpdate::new(&self.inner.changing_preferences);
self.inner.compact.set_active(preferences.compact);
self.inner.show_titles.set_active(preferences.show_titles);
self.inner.show_hints.set_active(preferences.show_hints);
}
let rows = self
.inner
.model
.borrow()
.sessions
.iter()
.map(|session| (session.row.clone(), session.detail.clone()))
.collect::<Vec<_>>();
for (row, detail) in rows {
apply_row_preferences(&row, &detail, preferences);
}
self.inner.hint.set_visible(preferences.show_hints);
reveal_selection(&self.inner);
let callbacks = self.inner.preferences_callbacks.borrow().clone();
for callback in callbacks {
if self.preferences() != preferences {
break;
}
callback(preferences);
}
}
pub fn connect_preferences_changed(&self, callback: impl Fn(SidebarPreferences) + 'static) {
self.inner
.preferences_callbacks
.borrow_mut()
.push(Rc::new(callback));
}
pub fn len(&self) -> usize {
self.inner.model.borrow().sessions.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn focus_search(&self) {
if let Some(popover) = self.inner.layout.popover() {
popover.popdown();
}
self.inner.search.grab_focus();
self.inner.search.select_region(0, -1);
}
pub fn connect_activated(&self, callback: impl Fn(SessionId) + 'static) {
self.inner
.activated_callbacks
.borrow_mut()
.push(Rc::new(callback));
}
pub fn connect_selected(&self, callback: impl Fn(Option<SessionId>) + 'static) {
self.inner
.selected_callbacks
.borrow_mut()
.push(Rc::new(callback));
}
pub fn connect_new_requested(&self, callback: impl Fn() + 'static) {
self.inner
.new_callbacks
.borrow_mut()
.push(Rc::new(callback));
}
pub fn connect_close_requested(&self, callback: impl Fn(SessionId) + 'static) {
self.inner
.close_callbacks
.borrow_mut()
.push(Rc::new(callback));
}
}
struct RowUpdate<'a> {
changing: &'a Cell<bool>,
previous: bool,
}
impl<'a> RowUpdate<'a> {
fn new(changing: &'a Cell<bool>) -> Self {
Self {
previous: changing.replace(true),
changing,
}
}
}
impl Drop for RowUpdate<'_> {
fn drop(&mut self) {
self.changing.set(self.previous);
}
}
fn clear_drop_indicator(row: >k::ListBoxRow) {
row.remove_css_class("liosbar-drop-before");
row.remove_css_class("liosbar-drop-after");
}
fn drop_destination(
inner: &Inner,
source: >k::ListBoxRow,
target: SessionId,
y: f64,
) -> Option<(SessionId, usize, gtk::ListBoxRow, bool)> {
let model = inner.model.borrow();
let from = model
.sessions
.iter()
.position(|session| session.row == *source)?;
let to = model
.sessions
.iter()
.position(|session| session.id == target)?;
let row = &model.sessions[to].row;
if from == to
|| !source.is_visible()
|| !row.is_visible()
|| row.height() <= 0
|| !y.is_finite()
|| y < 0.0
|| y > f64::from(row.height())
{
return None;
}
let after = y >= f64::from(row.height()) / 2.0;
let insertion = to + usize::from(after);
let destination = insertion - usize::from(from < insertion);
(destination != from).then(|| (model.sessions[from].id, destination, row.clone(), after))
}
fn install_row_drag(inner: &Rc<Inner>, row: >k::ListBoxRow, id: SessionId) {
row.set_tooltip_text(Some(
"Drag to reorder within this workspace; Ctrl+Shift+Page Up/Down moves the active session",
));
let source = gtk::DragSource::new();
source.set_button(1);
source.set_actions(gtk::gdk::DragAction::MOVE);
source.connect_prepare({
let weak = Rc::downgrade(inner);
move |_, _, _| {
let inner = weak.upgrade()?;
let row = inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.id == id && session.row.is_visible())?
.row
.clone();
Some(gtk::gdk::ContentProvider::for_value(&row.to_value()))
}
});
row.add_controller(source);
let target = gtk::DropTarget::new(gtk::ListBoxRow::static_type(), gtk::gdk::DragAction::MOVE);
target.set_preload(true);
let motion = {
let weak = Rc::downgrade(inner);
let row = row.downgrade();
move |target: >k::DropTarget, _: f64, y: f64| {
if let Some(row) = row.upgrade() {
clear_drop_indicator(&row);
}
let destination = weak.upgrade().and_then(|inner| {
let value = target.value()?;
let source = value.get::<gtk::ListBoxRow>().ok()?;
drop_destination(&inner, &source, id, y)
});
if let Some((_, _, row, after)) = destination {
row.add_css_class(if after {
"liosbar-drop-after"
} else {
"liosbar-drop-before"
});
gtk::gdk::DragAction::MOVE
} else {
gtk::gdk::DragAction::empty()
}
}
};
target.connect_enter(motion.clone());
target.connect_motion(motion);
target.connect_leave({
let row = row.downgrade();
move |_| {
if let Some(row) = row.upgrade() {
clear_drop_indicator(&row);
}
}
});
target.connect_drop({
let weak = Rc::downgrade(inner);
let row = row.downgrade();
move |_, value, _, y| {
if let Some(row) = row.upgrade() {
clear_drop_indicator(&row);
}
let Some(inner) = weak.upgrade() else {
return false;
};
let Ok(source) = value.get::<gtk::ListBoxRow>() else {
return false;
};
let Some((source, position, _, _)) = drop_destination(&inner, &source, id, y) else {
return false;
};
SessionWorkspace { inner }.reorder(source, position)
}
});
row.add_controller(target);
}
fn emit_selected(inner: &Inner, id: Option<SessionId>) {
let callbacks = inner.selected_callbacks.borrow().clone();
for callback in callbacks {
if inner.model.borrow().active != id {
break;
}
callback(id);
}
}
fn update_controls(inner: &Inner) {
let (can_add, can_close) = {
let model = inner.model.borrow();
(
model.sessions.len() < MAX_SESSIONS,
model
.sessions
.iter()
.any(|session| Some(session.id) == model.active && session.row.is_visible())
&& inner
.preview
.get()
.is_none_or(|id| Some(id) == model.active),
)
};
inner.new.set_sensitive(can_add);
inner.close.set_sensitive(can_close);
inner
.close
.set_tooltip_text(Some(if inner.preview.get().is_some() && !can_close {
"Press Enter to switch to the highlighted session before closing it"
} else {
"Request closing the active session"
}));
inner.new.set_tooltip_text(Some(if can_add {
"Request a new terminal session"
} else {
"32-session limit reached; close a session to add another"
}));
}
fn emit_activated(inner: &Inner, id: SessionId) {
let callbacks = inner.activated_callbacks.borrow().clone();
for callback in callbacks {
if inner.model.borrow().active != Some(id) {
break;
}
callback(id);
}
}
fn matches_filter(name: &str, detail: &str, query: &str) -> bool {
query.is_empty() || name.to_lowercase().contains(query) || detail.to_lowercase().contains(query)
}
fn visible_sessions(inner: &Inner) -> Vec<SessionId> {
inner
.model
.borrow()
.sessions
.iter()
.filter(|session| session.row.is_visible())
.map(|session| session.id)
.collect()
}
fn set_preview(inner: &Inner, id: Option<SessionId>) {
let rows = inner
.model
.borrow()
.sessions
.iter()
.map(|session| (session.id, session.row.clone()))
.collect::<Vec<_>>();
let target = rows
.iter()
.find(|(candidate, row)| Some(*candidate) == id && row.is_visible());
inner.preview.set(target.map(|(id, _)| *id));
for (candidate, row) in &rows {
if Some(*candidate) == inner.preview.get() {
row.add_css_class("liosbar-session-preview");
} else {
row.remove_css_class("liosbar-session-preview");
}
}
if let Some((_, row)) = target {
inner
.search
.update_relation(&[gtk::accessible::Relation::ActiveDescendant(
row.upcast_ref(),
)]);
} else {
inner
.search
.reset_relation(gtk::AccessibleRelation::ActiveDescendant);
}
update_controls(inner);
}
fn reconcile_preview(inner: &Inner, preferred: Option<SessionId>) {
let visible = visible_sessions(inner);
let next = if inner.search_focused.get() {
preferred
.filter(|id| visible.contains(id))
.or_else(|| visible.first().copied())
} else {
None
};
set_preview(inner, next);
}
fn filter_key(
inner: &Rc<Inner>,
key: gtk::gdk::Key,
modifiers: gtk::gdk::ModifierType,
) -> gtk::glib::Propagation {
use gtk::gdk::{Key, ModifierType};
use gtk::glib::Propagation;
let modifying = ModifierType::CONTROL_MASK
| ModifierType::SHIFT_MASK
| ModifierType::ALT_MASK
| ModifierType::SUPER_MASK
| ModifierType::META_MASK
| ModifierType::HYPER_MASK;
if !inner.search_focused.get() || modifiers.intersects(modifying) {
return Propagation::Proceed;
}
let offset = match key {
Key::Up | Key::KP_Up => -1,
Key::Down | Key::KP_Down => 1,
Key::Escape => {
inner.search.set_text("");
set_preview(inner, None);
reveal_selection(inner);
let active = inner.model.borrow().active;
if let Some(id) = active {
emit_activated(inner, id);
}
return Propagation::Stop;
}
_ => return Propagation::Proceed,
};
let visible = visible_sessions(inner);
if !visible.is_empty() {
let index = visible
.iter()
.position(|id| Some(*id) == inner.preview.get())
.map(|index| relative_index(index, offset, visible.len()))
.unwrap_or(if offset < 0 { visible.len() - 1 } else { 0 });
set_preview(inner, Some(visible[index]));
reveal_selection(inner);
}
Propagation::Stop
}
fn update_filter(inner: &Rc<Inner>) {
let query = inner.search.text().trim().to_lowercase();
let rows = inner
.model
.borrow()
.sessions
.iter()
.map(|session| {
(
session.row.clone(),
matches_filter(&session.name.text(), &session.detail.text(), &query),
)
})
.collect::<Vec<_>>();
let visible = rows.iter().filter(|(_, matched)| *matched).count();
let active_row = {
let model = inner.model.borrow();
model
.sessions
.iter()
.find(|session| Some(session.id) == model.active)
.map(|session| session.row.clone())
.filter(|active| rows.iter().any(|(row, matched)| *matched && row == active))
};
{
let _guard = RowUpdate::new(&inner.changing_rows);
for (row, matched) in &rows {
row.set_visible(*matched);
}
inner.list.select_row(active_row.as_ref());
}
inner.empty.set_label(if rows.is_empty() {
"No sessions yet"
} else {
"No matching sessions"
});
inner.count.set_label(&if query.is_empty() {
format!("{} / {MAX_SESSIONS} sessions", rows.len())
} else {
format!("{visible} of {} sessions", rows.len())
});
inner.search.set_icon_sensitive(
gtk::EntryIconPosition::Secondary,
!inner.search.text().is_empty(),
);
let previous = inner.preview.get();
reconcile_preview(inner, previous);
if previous != inner.preview.get() {
reveal_selection(inner);
}
}
fn reveal_selection(inner: &Rc<Inner>) {
inner.reveal_after_layout.set(true);
if inner.reveal_pending.replace(true) {
return;
}
let weak = Rc::downgrade(inner);
inner.paned.add_tick_callback(move |_, _| {
let Some(inner) = weak.upgrade() else {
return gtk::glib::ControlFlow::Break;
};
if inner.reveal_after_layout.replace(false) {
return gtk::glib::ControlFlow::Continue;
}
let row = {
let model = inner.model.borrow();
let target = inner.preview.get().or(model.active);
model
.sessions
.iter()
.find(|session| Some(session.id) == target)
.map(|session| session.row.clone())
};
if let Some(row) = row.filter(|row| row.is_visible()) {
if row.height() <= 0 {
return gtk::glib::ControlFlow::Continue;
}
let y = row.allocation().y() as f64;
inner
.scroll
.vadjustment()
.clamp_page(y, y + row.height() as f64);
}
inner.reveal_pending.set(false);
gtk::glib::ControlFlow::Break
});
}
fn arrange_sidebar(inner: &Inner) {
inner.position_pending.set(true);
inner.paned.set_start_child(None::<>k::Widget>);
inner.paned.set_end_child(None::<>k::Widget>);
match inner.side.get() {
SidebarSide::Left => {
inner.paned.set_start_child(Some(&inner.sidebar));
inner.paned.set_end_child(Some(&inner.stack));
inner.paned.set_resize_start_child(false);
inner.paned.set_resize_end_child(true);
inner.paned.set_shrink_start_child(false);
inner.paned.set_shrink_end_child(true);
}
SidebarSide::Right => {
inner.paned.set_start_child(Some(&inner.stack));
inner.paned.set_end_child(Some(&inner.sidebar));
inner.paned.set_resize_start_child(true);
inner.paned.set_resize_end_child(false);
inner.paned.set_shrink_start_child(true);
inner.paned.set_shrink_end_child(false);
}
}
position_sidebar(inner);
}
fn position_sidebar(inner: &Inner) -> bool {
if inner.paned.width() <= 0 {
return false;
}
let position = match inner.side.get() {
SidebarSide::Left => inner.sidebar_width.get(),
SidebarSide::Right => {
(inner.paned.width() - inner.sidebar_width.get() - inner.divider_width.get()).max(0)
}
};
inner.paned.set_position(position);
inner.position_pending.set(false);
true
}
fn remember_sidebar_width(inner: &Inner) {
if inner.position_pending.get() || inner.sidebar.width() <= 0 {
return;
}
inner.sidebar_width.set(inner.sidebar.width());
let divider = inner.paned.width() - inner.sidebar.width() - inner.stack.width();
inner.divider_width.set(divider.max(0));
}
fn session_row(name: &str) -> (gtk::ListBoxRow, gtk::Label, gtk::Label) {
let row = gtk::ListBoxRow::new();
row.add_css_class("liosbar-session-row");
let contents = gtk::Box::new(gtk::Orientation::Vertical, 2);
contents.set_margin_start(12);
contents.set_margin_end(12);
contents.set_margin_top(9);
contents.set_margin_bottom(9);
let name = gtk::Label::new(Some(name));
name.add_css_class("liosbar-session-name");
name.set_halign(gtk::Align::Start);
name.set_ellipsize(pango::EllipsizeMode::End);
name.set_tooltip_text(Some(name.text().as_str()));
let detail = gtk::Label::new(None);
detail.add_css_class("liosbar-session-detail");
detail.set_halign(gtk::Align::Start);
detail.set_ellipsize(pango::EllipsizeMode::End);
detail.set_visible(false);
contents.append(&name);
contents.append(&detail);
row.set_child(Some(&contents));
(row, name, detail)
}
fn apply_row_preferences(
row: >k::ListBoxRow,
detail: >k::Label,
preferences: SidebarPreferences,
) {
if let Some(contents) = row.child() {
let padding = if preferences.compact { 3 } else { 9 };
contents.set_margin_top(padding);
contents.set_margin_bottom(padding);
}
detail.set_visible(
(preferences.show_titles || row.has_css_class("liosbar-session-exited"))
&& !detail.text().is_empty(),
);
}
fn relative_index(current: usize, offset: isize, length: usize) -> usize {
((current as isize + offset.rem_euclid(length as isize)) % length as isize) as usize
}
fn normalized_text(value: &str, limit: usize) -> Option<String> {
let mut output = String::new();
let mut pending_space = false;
let mut count = 0;
for character in value.chars().take(MAX_LABEL_INPUT_CHARS) {
if character.is_whitespace() {
pending_space = !output.is_empty();
continue;
}
if character.is_control() || is_directional_control(character) {
continue;
}
if pending_space {
if count >= limit {
break;
}
output.push(' ');
count += 1;
pending_space = false;
}
if count >= limit {
break;
}
output.push(character);
count += 1;
}
let output = output.trim_end().to_string();
(!output.is_empty()).then_some(output)
}
fn is_directional_control(character: char) -> bool {
matches!(character, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidebar_preferences_preserve_original_defaults() {
assert_eq!(
SidebarPreferences::default(),
SidebarPreferences {
compact: false,
show_titles: true,
show_hints: true,
}
);
}
#[test]
fn labels_are_plain_bounded_and_control_free() {
assert_eq!(
normalized_text(" build\n server\t ", 48).as_deref(),
Some("build server")
);
assert_eq!(
normalized_text("\0\u{1b}\u{202e}ok\u{2066}", 48).as_deref(),
Some("ok")
);
assert_eq!(normalized_text(" \n\t\u{202e}", 48), None);
assert_eq!(
normalized_text("<b>shell</b>", 48).as_deref(),
Some("<b>shell</b>")
);
assert_eq!(normalized_text("é🦎界x", 3).as_deref(), Some("é🦎界"));
assert_eq!(normalized_text("a b", 2).as_deref(), Some("a"));
assert_eq!(normalized_text("name", 0), None);
}
#[test]
fn relative_navigation_wraps_even_for_extreme_offsets() {
assert_eq!(relative_index(0, -1, 3), 2);
assert_eq!(relative_index(2, 1, 3), 0);
assert_eq!(relative_index(1, 0, 3), 1);
for offset in [isize::MIN, isize::MAX] {
assert!(relative_index(2, offset, 3) < 3);
}
}
#[test]
fn session_filter_is_literal_and_matches_name_or_title() {
assert!(matches_filter("Compiler", "cargo check", "comp"));
assert!(matches_filter("Build", "ÉCOLE", "école"));
assert!(matches_filter("Any", "", ""));
assert!(!matches_filter("Compiler", "cargo check", ".*"));
assert!(!matches_filter("Compiler", "cargo check", "missing"));
}
#[test]
fn label_normalization_bounds_input_work_even_without_printable_text() {
let input = format!("{}tail", "\u{202e}".repeat(MAX_LABEL_INPUT_CHARS));
assert_eq!(normalized_text(&input, 48), None);
}
fn allocate_frames() {
let main_loop = gtk::glib::MainLoop::new(None, false);
gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(100), {
let main_loop = main_loop.clone();
move || main_loop.quit()
});
main_loop.run();
}
fn assert_sidebar_allocation(workspace: &SessionWorkspace, expected: i32) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
allocate_frames();
if (workspace.inner.sidebar.width() - expected).abs() <= 8
&& !workspace.inner.position_pending.get()
{
break;
}
assert!(
std::time::Instant::now() < deadline,
"sidebar did not settle at {expected}px: got {}px, divider {}, pending {}",
workspace.inner.sidebar.width(),
workspace.inner.paned.position(),
workspace.inner.position_pending.get(),
);
}
let sidebar = workspace.inner.sidebar.width();
let terminal = workspace.inner.stack.width();
assert!(
(sidebar - expected).abs() <= 8,
"sidebar should be approximately {expected}px, got {sidebar}px"
);
assert!(
terminal >= 600,
"terminal should remain broad, got {terminal}px"
);
}
fn press_filter_key(
workspace: &SessionWorkspace,
key: gtk::gdk::Key,
modifiers: gtk::gdk::ModifierType,
) -> bool {
let controllers = workspace.inner.search.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)
.expect("the filter's capture key controller is installed");
keys.emit_by_name("key-pressed", &[&key, &0_u32, &modifiers])
}
fn exercise_layout_preferences(
workspace: &SessionWorkspace,
window: >k::Window,
ids: &[SessionId],
) {
let active = workspace.active();
let content = workspace.inner.stack.visible_child();
let updates = Rc::new(RefCell::new(Vec::new()));
workspace.connect_preferences_changed({
let updates = updates.clone();
move |preferences| updates.borrow_mut().push(preferences)
});
workspace.set_detail(ids[0], "Build title");
let row = workspace.inner.model.borrow().sessions[0].row.clone();
let detail = workspace.inner.model.borrow().sessions[0].detail.clone();
workspace.inner.layout.popup();
allocate_frames();
let popover = workspace.inner.layout.popover().unwrap();
assert!(popover.is_visible());
assert!(
popover.is_autohide(),
"the layout menu uses native outside-click dismissal"
);
workspace.inner.compact.set_active(true);
workspace.inner.show_titles.set_active(false);
workspace.inner.show_hints.set_active(false);
let compact = SidebarPreferences {
compact: true,
show_titles: false,
show_hints: false,
};
assert_eq!(workspace.preferences(), compact);
assert_eq!(
updates.borrow().len(),
3,
"each checkbox emits exactly one edit"
);
assert!(!detail.is_visible());
assert!(!workspace.inner.hint.is_visible());
assert_eq!(row.child().unwrap().margin_top(), 3);
assert_eq!(workspace.active(), active);
assert_eq!(workspace.inner.stack.visible_child(), content);
workspace.set_preferences(compact);
assert_eq!(
updates.borrow().len(),
3,
"reapplying identical settings is silent"
);
workspace.set_detail(ids[0], "Updated build title");
assert!(
!detail.is_visible(),
"new titles cannot undo the visibility preference"
);
workspace.set_exited(ids[0], true);
workspace.set_detail(ids[0], "Exited · status 1");
assert!(
detail.is_visible(),
"hiding shell titles must not hide exit information"
);
workspace.set_exited(ids[0], false);
assert!(!detail.is_visible());
workspace.set_detail(ids[0], "Updated build title");
workspace.inner.search.set_text("updated build title");
assert_eq!(
workspace.inner.count.text(),
"1 of 32 sessions",
"hidden details remain searchable"
);
assert_eq!(workspace.active(), active);
workspace.inner.search.set_text("");
let reset = popover
.child()
.unwrap()
.last_child()
.unwrap()
.downcast::<gtk::Button>()
.unwrap();
reset.emit_clicked();
assert_eq!(workspace.preferences(), SidebarPreferences::default());
assert_eq!(
updates.borrow().len(),
4,
"reset is one edit, not three intermediate saves"
);
assert!(detail.is_visible());
assert!(workspace.inner.hint.is_visible());
assert_eq!(row.child().unwrap().margin_top(), 9);
workspace.focus_search();
assert!(
!popover.is_visible(),
"opening the filter dismisses the layout input grab"
);
gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>);
for index in [5, 15, 25, 31] {
workspace.select(ids[index]);
for preferences in [compact, SidebarPreferences::default()] {
workspace.set_preferences(preferences);
allocate_frames();
let row = workspace.inner.model.borrow().sessions[index].row.clone();
let adjustment = workspace.inner.scroll.vadjustment();
assert!(f64::from(row.allocation().y()) + 2.0 >= adjustment.value());
assert!(
f64::from(row.allocation().y() + row.height())
<= adjustment.value() + adjustment.page_size() + 2.0
);
}
}
let probe = SessionWorkspace::new(SidebarSide::Left);
probe.set_preferences(compact);
let id = probe
.add(>k::Label::new(Some("retained content")), "New")
.unwrap();
probe.set_detail(id, "Hidden title");
assert!(!probe.inner.model.borrow().sessions[0].detail.is_visible());
assert_eq!(
probe.inner.model.borrow().sessions[0]
.row
.child()
.unwrap()
.margin_top(),
3
);
probe.connect_preferences_changed({
let weak = Rc::downgrade(&probe.inner);
move |preferences| {
if preferences.compact {
if let Some(inner) = weak.upgrade() {
SessionWorkspace { inner }.set_preferences(SidebarPreferences::default());
}
}
}
});
let notified = Rc::new(RefCell::new(Vec::new()));
probe.connect_preferences_changed({
let notified = notified.clone();
move |preferences| notified.borrow_mut().push(preferences)
});
probe.set_preferences(SidebarPreferences::default());
notified.borrow_mut().clear();
probe.set_preferences(compact);
assert_eq!(
&*notified.borrow(),
&[SidebarPreferences::default()],
"superseded reentrant edits are not delivered"
);
assert_eq!(probe.preferences(), SidebarPreferences::default());
workspace.select(active.unwrap());
}
fn exercise_reordering(workspace: &SessionWorkspace, window: >k::Window, ids: &[SessionId]) {
let active = workspace.active();
let content = workspace.inner.stack.visible_child();
let rows = workspace
.inner
.model
.borrow()
.sessions
.iter()
.map(|session| session.row.clone())
.collect::<Vec<_>>();
let notifications = Rc::new(Cell::new(0));
workspace.connect_selected({
let notifications = notifications.clone();
move |_| notifications.set(notifications.get() + 1)
});
workspace.connect_activated({
let notifications = notifications.clone();
move |_| notifications.set(notifications.get() + 1)
});
assert!(!workspace.reorder(SessionId(u64::MAX), 0));
assert!(!workspace.reorder(ids[0], usize::MAX));
assert!(!workspace.reorder(ids[0], 0));
workspace.focus_search();
workspace.inner.search.set_text("Terminal 1");
let preview = workspace.inner.preview.get();
assert!(workspace.reorder(ids[1], 0));
assert_eq!(workspace.session_ids()[0], ids[1]);
assert_eq!(workspace.inner.search.text(), "Terminal 1");
assert!(workspace.inner.search_focused.get());
assert_eq!(workspace.inner.preview.get(), preview);
assert_eq!(workspace.active(), active);
assert_eq!(workspace.inner.stack.visible_child(), content);
workspace.inner.search.set_text("");
gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>);
assert!(workspace.move_active(isize::MIN));
assert_eq!(workspace.session_ids()[0], active.unwrap());
assert!(!workspace.move_active(-1));
assert!(workspace.move_active(isize::MAX));
assert_eq!(workspace.session_ids().last().copied(), active);
assert!(!workspace.move_active(1));
assert!(!workspace.move_active(0));
allocate_frames();
let controllers = rows[0].observe_controllers();
let source = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.find_map(|controller| controller.downcast::<gtk::DragSource>().ok())
.unwrap();
assert_eq!(source.actions(), gtk::gdk::DragAction::MOVE);
assert!(
source
.emit_by_name::<Option<gtk::gdk::ContentProvider>>("prepare", &[&0.0_f64, &0.0_f64])
.is_some()
);
let controllers = rows[20].observe_controllers();
let target = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.find_map(|controller| controller.downcast::<gtk::DropTarget>().ok())
.unwrap();
let drop = |value: gtk::glib::Value, y: f64| {
target.emit_by_name::<bool>(
"drop",
&[>k::glib::value::BoxedValue(value), &0.0_f64, &y],
)
};
assert!(!drop("untrusted external text".to_value(), 0.0));
let foreign = SessionWorkspace::new(SidebarSide::Left);
let foreign_id = foreign
.add(>k::Label::new(None), "Other workspace")
.unwrap();
assert_eq!(foreign_id, ids[0], "IDs can collide across workspaces");
let foreign_row = foreign.inner.model.borrow().sessions[0].row.clone();
assert!(!drop(foreign_row.to_value(), 0.0));
assert!(foreign.remove(foreign_id));
assert!(!drop(foreign_row.to_value(), 0.0));
assert!(!drop(rows[0].to_value(), f64::NAN));
assert!(!drop(rows[0].to_value(), -1.0));
assert!(!drop(rows[20].to_value(), 0.0));
rows[20].add_css_class("liosbar-drop-before");
assert!(drop(rows[0].to_value(), 0.0));
assert!(!rows[20].has_css_class("liosbar-drop-before"));
let order = workspace.session_ids();
let position = order.iter().position(|id| *id == ids[20]).unwrap();
assert_eq!(order[position - 1], ids[0]);
assert!(drop(rows[0].to_value(), f64::from(rows[20].height())));
let order = workspace.session_ids();
let position = order.iter().position(|id| *id == ids[20]).unwrap();
assert_eq!(order[position + 1], ids[0]);
assert_eq!(workspace.len(), MAX_SESSIONS);
assert_eq!(workspace.active(), active);
assert_eq!(workspace.inner.stack.visible_child(), content);
assert_eq!(
notifications.get(),
0,
"reordering must not select or activate content"
);
workspace.select(ids[20]);
workspace.select_relative(1);
assert_eq!(
workspace.active(),
Some(ids[0]),
"navigation follows the reordered rows"
);
for (position, id) in ids.iter().enumerate() {
workspace.reorder(*id, position);
}
assert_eq!(workspace.session_ids(), ids);
for (index, row) in rows.iter().enumerate() {
assert_eq!(row.index(), index as i32);
}
workspace.select(active.unwrap());
let empty = SessionWorkspace::new(SidebarSide::Left);
assert!(!empty.move_active(isize::MAX));
}
fn exercise_filter_navigation(
workspace: &SessionWorkspace,
window: >k::Window,
ids: &[SessionId],
contents: &[gtk::Label],
) {
use gtk::gdk::{Key, ModifierType};
workspace.select(ids[31]);
workspace.focus_search();
assert!(workspace.inner.search_focused.get());
assert_eq!(workspace.inner.preview.get(), Some(ids[31]));
let selections = Rc::new(Cell::new(0));
workspace.connect_selected({
let selections = selections.clone();
move |_| selections.set(selections.get() + 1)
});
workspace.inner.search.set_text("terminal 1");
assert_eq!(workspace.inner.preview.get(), Some(ids[1]));
assert!(press_filter_key(
workspace,
Key::Down,
ModifierType::empty()
));
assert_eq!(workspace.inner.preview.get(), Some(ids[10]));
workspace.set_detail(ids[10], "Updated background title");
assert_eq!(
workspace.inner.preview.get(),
Some(ids[10]),
"title updates retain the candidate ID"
);
assert_eq!(workspace.active(), Some(ids[31]));
assert_eq!(
workspace.inner.stack.visible_child(),
Some(contents[31].clone().upcast())
);
assert!(workspace.inner.search_focused.get());
assert!(workspace.inner.list.selected_row().is_none());
assert!(!workspace.inner.close.is_sensitive());
let rows = workspace
.inner
.model
.borrow()
.sessions
.iter()
.map(|session| session.row.clone())
.collect::<Vec<_>>();
assert!(rows[10].has_css_class("liosbar-session-preview"));
assert!(!rows[1].has_css_class("liosbar-session-preview"));
assert!(press_filter_key(
workspace,
Key::KP_Up,
ModifierType::empty()
));
assert_eq!(workspace.inner.preview.get(), Some(ids[1]));
assert!(press_filter_key(workspace, Key::Up, ModifierType::empty()));
assert_eq!(
workspace.inner.preview.get(),
Some(ids[19]),
"preview wraps only through matches"
);
assert!(press_filter_key(
workspace,
Key::KP_Down,
ModifierType::empty()
));
assert_eq!(workspace.inner.preview.get(), Some(ids[1]));
for modifier in [
ModifierType::CONTROL_MASK,
ModifierType::SHIFT_MASK,
ModifierType::ALT_MASK,
ModifierType::SUPER_MASK,
ModifierType::META_MASK,
ModifierType::HYPER_MASK,
] {
for key in [Key::Down, Key::Up, Key::Escape] {
assert!(!press_filter_key(workspace, key, modifier));
assert_eq!(workspace.inner.preview.get(), Some(ids[1]));
assert_eq!(workspace.inner.search.text(), "terminal 1");
}
}
assert!(!press_filter_key(
workspace,
Key::Home,
ModifierType::empty()
));
assert!(!press_filter_key(
workspace,
Key::End,
ModifierType::empty()
));
workspace.inner.search.set_text("terminal");
assert!(press_filter_key(workspace, Key::Up, ModifierType::empty()));
assert_eq!(workspace.inner.preview.get(), Some(ids[30]));
allocate_frames();
let adjustment = workspace.inner.scroll.vadjustment();
assert!(
adjustment.value() > 0.0,
"preview scrolls into view without selecting content"
);
assert!(
f64::from(rows[30].allocation().y() + rows[30].height())
<= adjustment.value() + adjustment.page_size() + 2.0
);
workspace.inner.search.set_text("no session matches this");
assert_eq!(workspace.inner.preview.get(), None);
assert!(press_filter_key(
workspace,
Key::Down,
ModifierType::empty()
));
workspace.inner.search.emit_activate();
assert_eq!(workspace.active(), Some(ids[31]));
assert_eq!(
selections.get(),
0,
"preview and empty results cannot emit selection"
);
workspace.inner.search.set_text("terminal 1");
assert!(press_filter_key(
workspace,
Key::Down,
ModifierType::empty()
));
workspace.inner.search.emit_activate();
assert_eq!(
workspace.active(),
Some(ids[10]),
"Enter opens the candidate, not the first match"
);
assert_eq!(selections.get(), 1);
assert_eq!(workspace.inner.preview.get(), None);
workspace.inner.search.set_text("terminal 2");
assert_eq!(workspace.inner.preview.get(), Some(ids[2]));
assert!(press_filter_key(
workspace,
Key::Escape,
ModifierType::empty()
));
assert!(workspace.inner.search.text().is_empty());
assert_eq!(workspace.active(), Some(ids[10]));
assert_eq!(workspace.inner.preview.get(), None);
workspace.inner.search.set_text("terminal 2");
gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>);
assert!(!workspace.inner.search_focused.get());
assert_eq!(
workspace.inner.preview.get(),
None,
"leaving the filter removes the preview"
);
assert!(
rows.iter()
.all(|row| !row.has_css_class("liosbar-session-preview"))
);
workspace.inner.search.set_text("");
}
#[test]
#[ignore = "requires an isolated GTK display; run with Xvfb and --test-threads=1"]
fn widget_sessions_preserve_identity_and_allow_reentrant_callbacks() {
gtk::init().expect("isolated GTK display is available");
let workspace = SessionWorkspace::new(SidebarSide::Right);
let window = gtk::Window::new();
window.set_child(Some(workspace.widget()));
window.set_size_request(960, 600);
window.set_default_size(960, 600);
window.present();
assert_sidebar_allocation(&workspace, SIDEBAR_WIDTH);
assert!(workspace.is_empty());
assert_eq!(workspace.active(), None);
assert_eq!(
workspace.add(workspace.widget(), "self"),
Err(AddError::InvalidContent)
);
assert_eq!(
workspace.add(&window, "ancestor"),
Err(AddError::InvalidContent)
);
let mut ids = Vec::new();
let mut contents = Vec::new();
for number in 0..MAX_SESSIONS {
let content = gtk::Label::new(Some(&format!("content {number}")));
let id = workspace
.add(&content, &format!("Terminal {number}"))
.unwrap();
ids.push(id);
contents.push(content);
assert_eq!(workspace.active(), Some(id));
}
assert_eq!(workspace.len(), MAX_SESSIONS);
assert_eq!(workspace.inner.count.text(), "32 / 32 sessions");
exercise_layout_preferences(&workspace, &window, &ids);
exercise_reordering(&workspace, &window, &ids);
allocate_frames();
let row = workspace
.inner
.model
.borrow()
.sessions
.last()
.unwrap()
.row
.clone();
let adjustment = workspace.inner.scroll.vadjustment();
assert!(
adjustment.value() > 0.0,
"keyboard/new-session selection scrolls into view"
);
assert!(
f64::from(row.allocation().y() + row.height())
<= adjustment.value() + adjustment.page_size() + 2.0
);
workspace.inner.search.set_text("TERMINAL 1");
assert_eq!(workspace.inner.count.text(), "11 of 32 sessions");
assert_eq!(
workspace.active(),
ids.last().copied(),
"filtering never changes the running session"
);
assert!(
!workspace.inner.close.is_sensitive(),
"cannot accidentally close a filtered-out session using the button"
);
assert!(workspace.inner.list.selected_row().is_none());
workspace.inner.search.set_text(".*");
assert_eq!(workspace.inner.count.text(), "0 of 32 sessions");
assert_eq!(workspace.inner.empty.text(), "No matching sessions");
assert_eq!(
workspace.inner.stack.visible_child(),
contents.last().map(|widget| widget.clone().upcast())
);
workspace.set_detail(ids[31], "Build ÉCOLE");
workspace.inner.search.set_text("école");
assert_eq!(workspace.inner.count.text(), "1 of 32 sessions");
assert_eq!(workspace.inner.list.selected_row(), Some(row));
workspace.rename(ids[31], "Compiler");
workspace.inner.search.set_text("compiler");
assert_eq!(workspace.inner.count.text(), "1 of 32 sessions");
workspace.set_detail(ids[31], "new title");
assert_eq!(workspace.inner.count.text(), "1 of 32 sessions");
let activated = Rc::new(RefCell::new(Vec::new()));
workspace.connect_activated({
let activated = activated.clone();
move |id| activated.borrow_mut().push(id)
});
workspace.inner.search.set_text("terminal 1");
workspace.inner.search.emit_activate();
assert_eq!(workspace.active(), Some(ids[1]));
let row = workspace.inner.model.borrow().sessions[1].row.clone();
workspace
.inner
.list
.emit_by_name::<()>("row-activated", &[&row]);
assert_eq!(
&*activated.borrow(),
&[ids[1], ids[1]],
"clicking the selected row can refocus its terminal"
);
exercise_filter_navigation(&workspace, &window, &ids, &contents);
workspace.inner.search.set_text("terminal 1");
workspace.select(ids[31]);
assert!(
workspace.inner.search.text().is_empty(),
"explicit navigation reveals a filtered-out target"
);
assert_eq!(workspace.inner.count.text(), "32 / 32 sessions");
assert!(!workspace.inner.new.is_sensitive());
let extra = gtk::Label::new(None);
assert_eq!(
workspace.add(&extra, "extra"),
Err(AddError::SessionLimitReached)
);
assert!(extra.parent().is_none());
assert_eq!(
workspace.add(&contents[0], "duplicate"),
Err(AddError::AlreadyParented)
);
workspace.select(ids[5]);
let active_widget = contents[5].clone().upcast::<gtk::Widget>();
workspace.set_sidebar_side(SidebarSide::Left);
assert_sidebar_allocation(&workspace, SIDEBAR_WIDTH);
workspace.set_sidebar_side(SidebarSide::Right);
assert_sidebar_allocation(&workspace, SIDEBAR_WIDTH);
assert_eq!(workspace.sidebar_side(), SidebarSide::Right);
assert_eq!(
workspace.inner.stack.visible_child(),
Some(active_widget.clone())
);
workspace.set_sidebar_side(SidebarSide::Left);
assert_sidebar_allocation(&workspace, SIDEBAR_WIDTH);
assert_eq!(workspace.inner.stack.visible_child(), Some(active_widget));
workspace.inner.paned.set_position(310);
assert_sidebar_allocation(&workspace, 310);
workspace.set_sidebar_side(SidebarSide::Right);
assert_sidebar_allocation(&workspace, 310);
workspace.set_sidebar_side(SidebarSide::Left);
assert_sidebar_allocation(&workspace, 310);
window.set_visible(false);
window.present();
assert_sidebar_allocation(&workspace, 310);
workspace.focus_search();
workspace.inner.search.set_text("terminal 2");
assert_eq!(workspace.inner.preview.get(), Some(ids[2]));
assert!(workspace.remove(ids[2]));
assert_eq!(workspace.active(), Some(ids[5]));
assert_eq!(
workspace.inner.search.text(),
"terminal 2",
"inactive removal must not erase a filter"
);
assert_eq!(
workspace.inner.preview.get(),
Some(ids[20]),
"a removed candidate cannot survive as a stale ID"
);
assert!(
!workspace
.inner
.list
.selected_row()
.is_some_and(|row| row.has_css_class("liosbar-session-preview"))
);
assert!(press_filter_key(
&workspace,
gtk::gdk::Key::Escape,
gtk::gdk::ModifierType::empty()
));
gtk::prelude::GtkWindowExt::set_focus(&window, None::<>k::Widget>);
assert!(contents[2].parent().is_none());
assert!(workspace.inner.new.is_sensitive());
assert!(!workspace.remove(ids[2]));
assert!(!workspace.select(ids[2]));
assert!(!workspace.rename(ids[2], "gone"));
assert!(!workspace.set_detail(ids[2], "gone"));
assert!(!workspace.set_exited(ids[2], true));
assert!(!workspace.rename(ids[5], " \0 "));
assert!(workspace.rename(ids[5], "renamed"));
assert!(workspace.set_detail(ids[5], "<b>running</b>\u{202e}"));
assert!(workspace.set_exited(ids[5], true));
let row = workspace
.inner
.model
.borrow()
.sessions
.iter()
.find(|session| session.id == ids[5])
.unwrap()
.row
.clone();
assert!(row.has_css_class("liosbar-session-exited"));
assert!(workspace.set_exited(ids[5], false));
let emitted = Rc::new(RefCell::new(Vec::new()));
workspace.connect_selected({
let inner = Rc::downgrade(&workspace.inner);
let emitted = emitted.clone();
let remove_on_select = ids[10];
move |selected| {
emitted.borrow_mut().push(selected);
let Some(inner) = inner.upgrade() else {
return;
};
let workspace = SessionWorkspace { inner };
assert!(workspace.len() <= MAX_SESSIONS);
if selected == Some(remove_on_select) {
assert!(workspace.remove(remove_on_select));
}
}
});
workspace.select(ids[10]);
assert!(!workspace.select(ids[10]));
assert_eq!(workspace.active(), Some(ids[11]));
assert_eq!(&*emitted.borrow(), &[Some(ids[10]), Some(ids[11])]);
workspace.select(ids[11]);
assert_eq!(emitted.borrow().len(), 2);
workspace.select(ids[0]);
workspace.select_relative(-1);
assert_eq!(workspace.active(), ids.last().copied());
workspace.select_relative(1);
assert_eq!(workspace.active(), Some(ids[0]));
let requests = Rc::new(Cell::new(0));
workspace.connect_new_requested({
let requests = requests.clone();
move || requests.set(requests.get() + 1)
});
workspace.inner.new.emit_clicked();
assert_eq!(requests.get(), 1);
let close_id = Rc::new(Cell::new(None));
workspace.connect_close_requested({
let close_id = close_id.clone();
move |id| close_id.set(Some(id))
});
workspace.inner.close.emit_clicked();
assert_eq!(close_id.get(), Some(ids[0]));
assert_eq!(workspace.active(), Some(ids[0]));
for id in ids {
workspace.remove(id);
}
assert!(workspace.is_empty());
assert_eq!(workspace.inner.empty.text(), "No sessions yet");
assert_eq!(workspace.inner.count.text(), "0 / 32 sessions");
assert_eq!(workspace.active(), None);
assert_eq!(emitted.borrow().last(), Some(&None));
assert!(!workspace.inner.close.is_sensitive());
let new_id = workspace.add(&extra, "").unwrap();
assert!(new_id.0 > MAX_SESSIONS as u64);
window.destroy();
let weak = Rc::downgrade(&workspace.inner);
drop(workspace);
assert!(
weak.upgrade().is_none(),
"widget signal handlers must not retain the workspace"
);
}
}