Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions crates/nebula-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ pub enum Focus {
/// What a screen cell maps to; rebuilt on every draw for hit-testing.
#[derive(Debug, Clone, PartialEq)]
pub enum HitTarget {
/// The GRID's background (registered after the cards, so they win).
/// The GRID's background (registered after the cards, so they win),
/// or a PANELS column's.
PanelBg(Focus),
/// A row of the PANELS — a project, a checkout (or pull request, or
/// issue), a session — or a group header there that folds
/// (`crate::panels::Row`). Registered ahead of its column's
/// `PanelBg`, so it wins.
PanelsRow(crate::panels::Row),
TerminalPane,
/// The session URL on the CLOUD SESSION PANEL; a click opens it in the
/// browser. Registered ahead of the pane it sits on, so it wins.
Expand Down Expand Up @@ -3286,6 +3292,19 @@ pub struct App {
/// (`event_loop::apply_config`). What a frame lays out is
/// [`App::panel_layout`].
pub launcher_list: bool,
/// The body is the PANELS — PROJECTS | WORKTREES | SESSIONS beside the
/// pane — rather than the PROJECT TABS over the GRID: Settings →
/// Appearance → **Layout** (`event_loop::apply_config`). Only a
/// switch: both draw the one selection (`sel_project`, `sel_worktree`,
/// `sel_session`) and the one pane, so flipping it mid-session lands
/// on the same rows with the same session attached. What a frame
/// draws is [`App::panels_active`].
pub panels: bool,
/// The PANELS' columns' scroll — PROJECTS, WORKTREES, SESSIONS, in
/// that order ([`crate::panels::ColumnScroll`]): the wheel moves a
/// column under its cursor, which stays put, and a move of the
/// cursor brings it back on screen. Drawn by `ui::panels_view`.
pub panels_scroll: [crate::panels::ColumnScroll; 3],
/// Every BAND is laid out open at once — its cards wrapped into rows,
/// or every entry of the LIST listed — and there is no ACCORDION:
/// Settings → Appearance → **Expand all worktrees**
Expand Down Expand Up @@ -3859,6 +3878,8 @@ impl App {
launcher_pane_w: None,
launcher_pane_at: crate::launcher::PaneSide::default(),
launcher_list: false,
panels: false,
panels_scroll: Default::default(),
launcher_all_open: false,
launcher_pane_hidden: false,
launcher_expanded: None,
Expand Down Expand Up @@ -4142,7 +4163,17 @@ impl App {
/// no session has been opened full-screen over it (`collapsed`, which
/// `ui::draw` hands to the pane before it ever reaches the view).
pub fn launcher_grid(&self) -> bool {
self.launcher_active() && !self.collapsed
self.launcher_active() && !self.collapsed && !self.panels
}

/// The PANELS are what the body draws in place of the GRID: the
/// **Layout** setting says so ([`App::panels`]) and there is a project
/// open to draw them for — the same gate as the LAUNCHER VIEW's, so
/// the first run and the all-tabs-closed SPLASH come first either way.
/// True under a full-screen session too, as [`App::launcher_active`]
/// is: the panels are what `^q` comes back down to.
pub fn panels_active(&self) -> bool {
self.panels && self.launcher_active()
}

/// Take the keyboard back from the session in the PANE, and say so
Expand Down Expand Up @@ -5471,9 +5502,10 @@ impl App {

/// The two places FOCUS can rest: the LAUNCHER VIEW's GRID of cards
/// and the PANE under them. The three columns the other variants name
/// are no longer drawn.
/// are drawn only by the PANELS ([`App::panels_active`]), where all
/// four are places to rest.
pub fn focus_visible(&self, focus: Focus) -> bool {
matches!(focus, Focus::Sessions | Focus::Terminal)
matches!(focus, Focus::Sessions | Focus::Terminal) || self.panels_active()
}

fn focus_rank(focus: Focus) -> u8 {
Expand Down
65 changes: 65 additions & 0 deletions crates/nebula-tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ pub const PANE_SIDES: &[&str] = &[
/// ([`crate::launcher::LIST_RECENT`]).
pub const WORKTREE_LAYOUTS: &[&str] = &["cards", "list"];

/// The **Layout** choices (Settings → Appearance), in the order the row
/// cycles them: the GRID — PROJECT TABS over the lit project's session
/// cards, the default — then the PANELS, the three columns nebula drew
/// before the grid (PROJECTS | WORKTREES | SESSIONS beside the pane,
/// `crate::panels`).
pub const LAYOUTS: &[&str] = &["grid", "panels"];

/// The **Preset text** choices (Settings → Sessions), in the order the row
/// cycles them: the [`PresetText`] sides by label.
pub const PRESET_TEXTS: &[&str] = &[
Expand Down Expand Up @@ -397,6 +404,7 @@ pub enum SettingKind {
Theme,
Animations,
BlackBackground,
Layout,
HideCardMarks,
HighlightCurrentCard,
SessionPane,
Expand Down Expand Up @@ -503,6 +511,7 @@ impl SettingKind {
| SettingKind::CardIssueNumber => (2026, 9, 24),
SettingKind::ExpandAllWorktrees | SettingKind::FollowNewSession => (2026, 9, 26),
SettingKind::HighlightCurrentCard => (2026, 9, 28),
SettingKind::Layout => (2026, 10, 3),
}
}

Expand Down Expand Up @@ -652,6 +661,12 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[
hint: "Paint the window pure black instead of the terminal's own background (off keeps the terminal's, transparency included)",
group: "",
},
SettingSpec {
kind: SettingKind::Layout,
label: "Layout",
hint: "The grid of session cards under project tabs, or the three panels — projects, worktrees, sessions — beside the session pane",
group: "",
},
SettingSpec {
kind: SettingKind::SessionPane,
label: "Session pane",
Expand Down Expand Up @@ -1074,6 +1089,12 @@ pub struct Config {
/// recent shown until Tab — the ACCORDION — opens the rest). Read
/// through [`Config::list_layout`], so a word off the list is the cards.
pub worktree_layout: String,
/// What the body draws: `grid` (the LAUNCHER VIEW — PROJECT TABS over
/// the GRID of cards, the default) or `panels` (the PROJECTS,
/// WORKTREES and SESSIONS columns beside the pane, `crate::panels`).
/// Read through [`Config::panels_layout`], so a word off the list is
/// the grid.
pub layout: String,
/// EXPAND ALL WORKTREES: every BAND on the GRID laid out open at once
/// — each worktree's sessions and terminals wrapped into rows under
/// its rule, every entry of the compact LIST listed — so there is no
Expand Down Expand Up @@ -1425,6 +1446,7 @@ impl Default for Config {
highlight_current_card: true,
session_pane: crate::launcher::PaneSide::default().as_str().into(),
worktree_layout: WORKTREE_LAYOUTS[0].into(),
layout: LAYOUTS[0].into(),
expand_all_worktrees: false,
hide_card_prompt: false,
card_issue_number: true,
Expand Down Expand Up @@ -1603,6 +1625,11 @@ impl Config {
self.worktree_layout.trim().eq_ignore_ascii_case("list")
}

/// `layout` says the PANELS rather than the grid.
pub fn panels_layout(&self) -> bool {
self.layout.trim().eq_ignore_ascii_case("panels")
}

/// The editor the file overlays launch: `NEBULA_EDITOR` when set,
/// otherwise the `editor` setting, otherwise vim.
pub fn editor_command(&self) -> String {
Expand Down Expand Up @@ -2234,6 +2261,7 @@ impl Config {
SettingKind::HighlightCurrentCard => on_off(self.highlight_current_card).into(),
SettingKind::SessionPane => self.pane_side().as_str().into(),
SettingKind::WorktreeLayout => WORKTREE_LAYOUTS[usize::from(self.list_layout())].into(),
SettingKind::Layout => LAYOUTS[usize::from(self.panels_layout())].into(),
SettingKind::ExpandAllWorktrees => on_off(self.expand_all_worktrees).into(),
SettingKind::CardIssueNumber => on_off(self.card_issue_number).into(),
SettingKind::HideDraftPrs => shown_hidden(self.hide_draft_prs).into(),
Expand Down Expand Up @@ -2344,6 +2372,10 @@ impl Config {
let now = WORKTREE_LAYOUTS[usize::from(self.list_layout())];
self.worktree_layout = cycle_choice(now, WORKTREE_LAYOUTS, step).into();
}
SettingKind::Layout => {
let now = LAYOUTS[usize::from(self.panels_layout())];
self.layout = cycle_choice(now, LAYOUTS, step).into();
}
SettingKind::ExpandAllWorktrees => {
self.expand_all_worktrees = !self.expand_all_worktrees;
}
Expand Down Expand Up @@ -3896,6 +3928,39 @@ mod tests {
assert!(!odd.list_layout(), "a word off the list");
}

/// The **Layout**: the grid out of the box, cycled from its Appearance
/// row to the PANELS and back, persisted under `layout`. A config
/// predating the key, or holding a word off the list, reads as the
/// grid.
#[test]
fn layout_defaults_to_the_grid_cycles_and_persists() {
let mut cfg = Config::default();
assert!(!cfg.panels_layout());
let (tab, row) = locate(SettingKind::Layout).unwrap();
assert_eq!(SETTINGS_TABS[tab].title, "Appearance");
assert_eq!(cfg.value_label(SettingKind::Layout), "grid");
cfg.cycle(tab, row, 1);
assert!(cfg.panels_layout());
assert_eq!(cfg.value_label(SettingKind::Layout), "panels");
cfg.cycle(tab, row, -1);
assert!(!cfg.panels_layout(), "and back");
cfg.cycle(tab, row, 0);
assert!(cfg.panels_layout(), "Enter steps it on too");

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
cfg.save_to(&path).unwrap();
assert!(load_from(&path).panels_layout());
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(raw.get("layout"), Some(&serde_json::json!("panels")));

let older: Config = serde_json::from_str("{}").unwrap();
assert!(!older.panels_layout(), "predating the key");
let odd: Config = serde_json::from_str(r#"{"layout": "cards"}"#).unwrap();
assert!(!odd.panels_layout(), "a word off the list");
}

/// The QUICK PROMPT's focus toggle: off unless the user turns it on,
/// and persisted under its own key (a missed `obj.insert` would let the
/// row toggle on screen and read back off on the next launch).
Expand Down
23 changes: 22 additions & 1 deletion crates/nebula-tui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod host_terminal;
mod launcher;
mod optimistic;
mod pacing;
mod panels;
mod placeholder;
mod quick_launch;
mod release_watch;
Expand Down Expand Up @@ -3140,6 +3141,11 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec<ClientRequest>) {
if app.launcher_grid() && launcher::handle_action(app, action, armed, &chord, out) {
return;
}
// The PANELS take the grid's own keys that mean something else — or
// nothing — beside the columns; the rest keep their panel meaning.
if app.panels_active() && !app.collapsed && panels::handle_action(app, action, out) {
return;
}
use crate::keymap::Action;
match action {
Action::Quit => app.overlay = Some(Overlay::Confirm(confirm_quit())),
Expand Down Expand Up @@ -4585,8 +4591,13 @@ fn confirm_archive_agent(name: &str, id: AgentId) -> ConfirmDialog {
///
/// INPUT PARITY: the key reaches `launcher::toggle_archived` through the
/// grid's own `handle_action`; the menu and everything else reaches it
/// here, so both ends land the cursor the same way.
/// here, so both ends land the cursor the same way. The PANELS fold their
/// SESSIONS column's ARCHIVED group in place instead.
fn toggle_archived(app: &mut App, out: &mut Vec<ClientRequest>) {
if app.panels_active() {
panels::toggle_archived(app, out);
return;
}
launcher::toggle_archived(app, out);
}

Expand Down Expand Up @@ -5395,6 +5406,7 @@ fn select_clicked_row(app: &mut App, target: &HitTarget, out: &mut Vec<ClientReq
}
HitTarget::LauncherBandPr(ref wid) => launcher::select_band_of(app, wid, out),
HitTarget::LauncherCardIssue(ref id) => launcher::select_issue_card(app, id, out),
HitTarget::PanelsRow(row) => panels::select_row(app, row, out),
_ => false,
}
}
Expand Down Expand Up @@ -6447,6 +6459,7 @@ fn apply_config(app: &mut App, cfg: &crate::config::Config) {
app.highlight_current_card = cfg.highlight_current_card;
app.launcher_pane_at = cfg.pane_side();
app.launcher_list = cfg.list_layout();
app.panels = cfg.panels_layout();
app.launcher_all_open = cfg.expand_all_worktrees;
set_hide_draft_prs(app, cfg.hide_draft_prs);
}
Expand Down Expand Up @@ -9638,6 +9651,10 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>)
// A BAND's rule: the cursor lands on the band, as `j`/`k`
// walking onto it do.
Some(HitTarget::LauncherBand(i)) => launcher::click_band(app, i, out),
// A PANELS row: the cursor lands on it, its column takes
// FOCUS; a second click is Enter on it. A group header
// folds its group.
Some(HitTarget::PanelsRow(row)) => panels::click_row(app, row, out),
// The `❮` / `❯` beside a band's row: one card that way
// along the band, the very step `h` / `l` take.
Some(HitTarget::LauncherStripLeft(i)) => {
Expand Down Expand Up @@ -9853,6 +9870,10 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec<ClientRequest>)
launcher::wheel_grid(app, up);
return;
}
// The PANELS' columns: a notch scrolls the column under it.
if app.panels_active() && !app.collapsed && panels::wheel(app, over.as_ref(), up) {
return;
}
let in_term = matches!(over, Some(HitTarget::TerminalPane)) || app.collapsed;
if in_term && app.reading_url().is_some() {
// The pane is showing a pull request or an issue, not a
Expand Down
1 change: 1 addition & 0 deletions crates/nebula-tui/src/event_loop/focus_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub(super) fn land_click_focus(app: &mut App, column: u16, row: u16, out: &mut V
| HitTarget::LauncherBandMore(_),
) => app.focus = Focus::Sessions,
Some(HitTarget::PanelBg(focus)) => app.focus = focus,
Some(HitTarget::PanelsRow(row)) => app.focus = row.focus(),
Some(HitTarget::TerminalPane | HitTarget::CloudSessionLink) => {
enter_terminal_pane(app, out)
}
Expand Down
5 changes: 3 additions & 2 deletions crates/nebula-tui/src/event_loop/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ pub(super) fn empty_band(app: &App) -> Option<WorktreeId> {
let on_card = app
.selected_session_row()
.is_some_and(|row| row.sref().is_some());
if !app.launcher_active() || on_card {
if !app.launcher_active() || app.panels || on_card {
return None;
}
let bands = view::bands(app);
Expand Down Expand Up @@ -2053,7 +2053,8 @@ pub(super) fn toggle_full_screen(app: &mut App, out: &mut Vec<ClientRequest>) ->
app.dirty = true;
if app.collapsed {
app.collapsed = false;
if app.launcher_pane_hidden || !has_pane(app) {
// The PANELS always have their pane beside the columns.
if !app.panels_active() && (app.launcher_pane_hidden || !has_pane(app)) {
super::leave_terminal_lock(app);
return "Back to the grid";
}
Expand Down
Loading