You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/25 03:40:15 UTC
(maka) branch feat/runtime-host-rust updated: feat(tui): navigate lists and tabs as keyboard focus groups
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
new ce1331af49 feat(tui): navigate lists and tabs as keyboard focus groups
ce1331af49 is described below
commit ce1331af4961354f7d9f5cb694ea4c12cf6faf40
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Sep 25 11:38:58 2026 +0800
feat(tui): navigate lists and tabs as keyboard focus groups
Give semantic lists a single Tab stop while preserving independent fields and confirmation controls. Reenter by stable item identity and reveal it through scrolling. Apply the same behavior to built-in controls and Rust or JavaScript presenter views.
Validation: 263 TUI and PTY tests, 37 final kernel and presenter tests, workspace all-targets Clippy, formatting, and Windows Terminal keyboard smoke.
Generated-by: Codex
---
crates/tui/src/apps.rs | 73 ++++++++++++
crates/tui/src/apps/page.rs | 2 +-
crates/tui/src/apps/tree.rs | 24 +++-
crates/tui/src/pages/attachments/view.rs | 4 +-
crates/tui/src/pages/interactions/form/view.rs | 2 +-
crates/tui/src/pages/interactions/question.rs | 9 +-
crates/tui/src/pages/manage/choose_project/view.rs | 6 +-
crates/tui/src/pages/manage/directory/view.rs | 4 +-
crates/tui/src/pages/manage/enabled_models/view.rs | 2 +-
crates/tui/src/pages/manage/models/view.rs | 6 +-
crates/tui/src/pages/onboarding/view.rs | 2 +-
crates/tui/src/pages/settings.rs | 1 +
crates/tui/src/pages/sidebar.rs | 2 +-
crates/tui/src/pages/skills.rs | 14 +++
crates/tui/src/pages/skills/view.rs | 2 +-
crates/tui/src/theme/editor/view.rs | 4 +-
crates/tui/src/ui/layout.rs | 10 ++
crates/tui/src/ui/node.rs | 9 ++
crates/tui/src/ui/surface.rs | 127 +++++++++++++++++++--
packages/plugin-sdk/README.md | 2 +
packages/plugin-sdk/README.zh-CN.md | 2 +
21 files changed, 280 insertions(+), 27 deletions(-)
diff --git a/crates/tui/src/apps.rs b/crates/tui/src/apps.rs
index 18e4084336..861b1d151e 100644
--- a/crates/tui/src/apps.rs
+++ b/crates/tui/src/apps.rs
@@ -1470,6 +1470,79 @@ pub(crate) mod tests {
assert_eq!(app.apps.directory.len(), 1);
}
+ #[test]
+ fn plugin_lists_and_tabs_share_keyboard_groups_without_submitting_or_trapping_fields() {
+ let mut app = app();
+ let mut view = form();
+ let items = (0..64)
+ .map(|index| {
+ link(
+ format!("item-{index}"),
+ format!("Item {index}"),
+ json!(index),
+ )
+ .current(index == 40)
+ .into()
+ })
+ .collect();
+ view.root = column(
+ "root",
+ vec![
+ tabs(
+ "tabs",
+ "all",
+ vec![
+ ("all".into(), "All".into(), json!("all")),
+ ("mine".into(), "Mine".into(), json!("mine")),
+ ],
+ ),
+ scroll("list", 5, column("items", items)),
+ column(
+ "mixed",
+ vec![
+ link("other", "Other", json!("other")).into(),
+ scroll("note", 3, text("text", "Read this first.", Tone::Normal)),
+ ],
+ ),
+ input("enabled", "enabled", "Enabled"),
+ input("name", "name", "Name"),
+ button("save", "save", Role::Primary),
+ ],
+ );
+ view.validate().unwrap();
+ instance_mut(&mut app).view = Some(view);
+ let root = "app/body/frame/content/root";
+ for (width, height) in [(100, 32), (44, 24)] {
+ instance_mut(&mut app)
+ .surface
+ .focus(format!("{root}/tabs/tabs/all"));
+ draw(&mut app, width, height);
+ for (code, target) in [
+ (KeyCode::Right, "tabs/tabs/mine"),
+ (KeyCode::Tab, "list/items/item-40"),
+ (KeyCode::Tab, "mixed/other"),
+ (KeyCode::Tab, "mixed/note"),
+ (KeyCode::Tab, "enabled"),
+ (KeyCode::Tab, "name"),
+ (KeyCode::BackTab, "enabled"),
+ (KeyCode::BackTab, "mixed/note"),
+ (KeyCode::BackTab, "mixed/other"),
+ (KeyCode::BackTab, "list/items/item-40"),
+ (KeyCode::BackTab, "tabs/tabs/mine"),
+ ] {
+ app.input(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
+ draw(&mut app, width, height);
+ let path = format!("{root}/{target}");
+ assert_eq!(instance(&app).surface.focused(), Some(path.as_str()));
+ assert!(!instance(&app).surface.rect(&path).unwrap().is_empty());
+ assert!(
+ next(&mut app).is_none(),
+ "focus does not execute plugin code"
+ );
+ }
+ }
+ }
+
#[test]
fn a_rich_view_navigates_routes_picks_choices_and_confirms_destructive_actions() {
let mut app = app();
diff --git a/crates/tui/src/apps/page.rs b/crates/tui/src/apps/page.rs
index 62e015fd80..2fbe4226e3 100644
--- a/crates/tui/src/apps/page.rs
+++ b/crates/tui/src/apps/page.rs
@@ -429,7 +429,7 @@ fn directory(app: &App, width: u16) -> Node<Message> {
key,
vec![
Node::text("title", vec![(i18n.text(title), Tone::Muted)]),
- Node::column("rows", rows).gap(1),
+ Node::column("rows", rows).gap(1).focus_group(),
],
));
}
diff --git a/crates/tui/src/apps/tree.rs b/crates/tui/src/apps/tree.rs
index 492ca7b68d..f04fc19d4e 100644
--- a/crates/tui/src/apps/tree.rs
+++ b/crates/tui/src/apps/tree.rs
@@ -251,6 +251,22 @@ impl<M> Builder<'_, M> {
let key = node.key().to_owned();
match node {
wire::Node::Column { gap, children, .. } => {
+ // Item-only collections are lists. Decorative headings may
+ // sit among them; inputs and actions keep separate Tab stops.
+ let list = children
+ .iter()
+ .any(|child| matches!(child, wire::Node::Item { .. }))
+ && children.iter().all(|child| {
+ matches!(
+ child,
+ wire::Node::Item { .. }
+ | wire::Node::Text { .. }
+ | wire::Node::Rule { .. }
+ | wire::Node::Markdown { .. }
+ | wire::Node::Code { .. }
+ | wire::Node::Progress { .. }
+ )
+ });
let children = children
.iter()
.map(|child| {
@@ -258,7 +274,8 @@ impl<M> Builder<'_, M> {
self.node(child, path, width, Axis::Column)
})
.collect();
- Node::column(key, children).gap(u16::from(*gap))
+ let node = Node::column(key, children).gap(u16::from(*gap));
+ if list { node.focus_group() } else { node }
}
wire::Node::Row { gap, children, .. } => self.row(key, path, *gap, children, width),
wire::Node::Text { spans, clip, .. } => text(key, spans, *clip),
@@ -295,7 +312,10 @@ impl<M> Builder<'_, M> {
.collect();
Node::column(
key,
- vec![Node::row("tabs", tabs).gap(3), Node::rule("rule")],
+ vec![
+ Node::row("tabs", tabs).gap(3).focus_group(),
+ Node::rule("rule"),
+ ],
)
}
wire::Node::Item {
diff --git a/crates/tui/src/pages/attachments/view.rs b/crates/tui/src/pages/attachments/view.rs
index 6023dd3169..fb240eac93 100644
--- a/crates/tui/src/pages/attachments/view.rs
+++ b/crates/tui/src/pages/attachments/view.rs
@@ -215,7 +215,7 @@ fn list(
.enabled(app.attachment_enabled(&add)),
);
let mut sheet = sheet.body(
- Node::scroll("list", Node::column("rows", rows))
+ Node::scroll("list", Node::column("rows", rows).focus_group())
.size(Size::Upto(height.saturating_sub(14).max(4))),
);
let selected = items.get(dialog.selected);
@@ -318,7 +318,7 @@ fn browse(app: &App, dialog: &Dialog, sheet: Sheet<Action>, height: u16) -> Shee
let empty = rows.is_empty();
if !empty {
sheet = sheet.body(
- Node::scroll("list", Node::column("rows", rows))
+ Node::scroll("list", Node::column("rows", rows).focus_group())
.size(Size::Upto(height.saturating_sub(14).max(4))),
);
}
diff --git a/crates/tui/src/pages/interactions/form/view.rs b/crates/tui/src/pages/interactions/form/view.rs
index 5c6499065b..ce7d600b89 100644
--- a/crates/tui/src/pages/interactions/form/view.rs
+++ b/crates/tui/src/pages/interactions/form/view.rs
@@ -276,7 +276,7 @@ impl Form {
}
if !choices.is_empty() {
entry.push(
- Node::scroll("choices", Node::column("rows", choices))
+ Node::scroll("choices", Node::column("rows", choices).focus_group())
.size(Size::Upto(height.saturating_sub(18).max(3))),
);
}
diff --git a/crates/tui/src/pages/interactions/question.rs b/crates/tui/src/pages/interactions/question.rs
index 2532df24ed..61b1a73901 100644
--- a/crates/tui/src/pages/interactions/question.rs
+++ b/crates/tui/src/pages/interactions/question.rs
@@ -259,12 +259,15 @@ impl Questions {
.clamp(1, 3);
// The written answer is the last choice, right under the others.
vec![
- Node::row("tabs", tabs).gap(1),
+ Node::row("tabs", tabs).focus_group().gap(1),
Node::column(
"question",
vec![
- Node::scroll("choices", Node::column("rows", choices).gap(1))
- .size(Size::Upto(height.saturating_sub(16).max(4))),
+ Node::scroll(
+ "choices",
+ Node::column("rows", choices).focus_group().gap(1),
+ )
+ .size(Size::Upto(height.saturating_sub(16).max(4))),
Node::row(
"answer",
vec![
diff --git a/crates/tui/src/pages/manage/choose_project/view.rs b/crates/tui/src/pages/manage/choose_project/view.rs
index e94db3906e..067ee5e014 100644
--- a/crates/tui/src/pages/manage/choose_project/view.rs
+++ b/crates/tui/src/pages/manage/choose_project/view.rs
@@ -85,8 +85,10 @@ pub(in crate::pages::manage) fn sheet(app: &App, dialog: &Dialog) -> Sheet<Actio
.collect();
let height = app.frame_size.map_or(24, |(_, height)| height);
let visible = (catalog.items.len() as u16).min(height.saturating_sub(16).max(3));
- sheet =
- sheet.body(Node::scroll("list", Node::column("rows", rows)).size(Size::Fixed(visible)));
+ sheet = sheet.body(
+ Node::scroll("list", Node::column("rows", rows).focus_group())
+ .size(Size::Fixed(visible)),
+ );
}
let (note, tone) = if busy {
("session-saving", Tone::Subtle)
diff --git a/crates/tui/src/pages/manage/directory/view.rs b/crates/tui/src/pages/manage/directory/view.rs
index 9d8d40ef7c..bf4ef8b195 100644
--- a/crates/tui/src/pages/manage/directory/view.rs
+++ b/crates/tui/src/pages/manage/directory/view.rs
@@ -142,7 +142,9 @@ pub(in crate::pages::manage) fn sheet(app: &App, dialog: &Dialog) -> Sheet<Actio
.collect();
let height = app.frame_size.map_or(24, |(_, height)| height);
let cap = height.saturating_sub(16 + references.len() as u16).max(3);
- sheet = sheet.body(Node::scroll("list", Node::column("rows", rows)).size(Size::Upto(cap)));
+ sheet = sheet.body(
+ Node::scroll("list", Node::column("rows", rows).focus_group()).size(Size::Upto(cap)),
+ );
}
let note = if busy {
Some(("session-saving", Tone::Subtle))
diff --git a/crates/tui/src/pages/manage/enabled_models/view.rs b/crates/tui/src/pages/manage/enabled_models/view.rs
index 605cdce267..8f8bd3829e 100644
--- a/crates/tui/src/pages/manage/enabled_models/view.rs
+++ b/crates/tui/src/pages/manage/enabled_models/view.rs
@@ -100,7 +100,7 @@ pub(in crate::pages::manage) fn sheet(app: &App, dialog: &Dialog) -> Sheet<Actio
.collect();
let height = app.frame_size.map_or(24, |(_, height)| height);
sheet = sheet.body(
- Node::scroll("list", Node::column("rows", rows))
+ Node::scroll("list", Node::column("rows", rows).focus_group())
.size(Size::Upto(height.saturating_sub(16).max(3))),
);
}
diff --git a/crates/tui/src/pages/manage/models/view.rs b/crates/tui/src/pages/manage/models/view.rs
index 31faada026..fa9099bf72 100644
--- a/crates/tui/src/pages/manage/models/view.rs
+++ b/crates/tui/src/pages/manage/models/view.rs
@@ -110,8 +110,10 @@ pub(in crate::pages::manage) fn sheet(app: &App, dialog: &Dialog) -> Sheet<Actio
if !rows.is_empty() {
let height = app.frame_size.map_or(24, |(_, height)| height);
let visible = lines.min(height.saturating_sub(18).max(4));
- sheet =
- sheet.body(Node::scroll("list", Node::column("rows", rows)).size(Size::Fixed(visible)));
+ sheet = sheet.body(
+ Node::scroll("list", Node::column("rows", rows).focus_group())
+ .size(Size::Fixed(visible)),
+ );
}
if catalog.rows.is_empty() && !catalog.error {
let key = match (catalog.ready(), catalog.can_previous()) {
diff --git a/crates/tui/src/pages/onboarding/view.rs b/crates/tui/src/pages/onboarding/view.rs
index efcd7d1489..15971f4a21 100644
--- a/crates/tui/src/pages/onboarding/view.rs
+++ b/crates/tui/src/pages/onboarding/view.rs
@@ -101,7 +101,7 @@ pub(crate) fn sheet(app: &App) -> Option<Sheet<Action>> {
sheet = sheet
.text("heading", &app.i18n.text("onboard-models"), Tone::Normal)
.body(
- Node::scroll("list", Node::column("rows", rows))
+ Node::scroll("list", Node::column("rows", rows).focus_group())
.size(Size::Upto(height.saturating_sub(14).max(3))),
);
} else {
diff --git a/crates/tui/src/pages/settings.rs b/crates/tui/src/pages/settings.rs
index 986d8e5c15..79e48f6ac7 100644
--- a/crates/tui/src/pages/settings.rs
+++ b/crates/tui/src/pages/settings.rs
@@ -242,6 +242,7 @@ fn tree(app: &App, width: u16) -> (Node<Message>, Vec<crate::apps::Well>) {
}));
children.push(
Node::column("categories", categories)
+ .focus_group()
.size(Size::Fixed(CATEGORIES))
.gap(1),
);
diff --git a/crates/tui/src/pages/sidebar.rs b/crates/tui/src/pages/sidebar.rs
index fd854a12ed..8aee34d3c0 100644
--- a/crates/tui/src/pages/sidebar.rs
+++ b/crates/tui/src/pages/sidebar.rs
@@ -196,7 +196,7 @@ fn tree(app: &App, orbit: Option<&'static str>, height: u16) -> Node<Message> {
let mut children = vec![
new.size(Size::Fixed(1)),
Node::text("gap", vec![]).size(Size::Fixed(1)),
- Node::scroll("list", Node::column("rows", rows)),
+ Node::scroll("list", Node::column("rows", rows).focus_group()),
];
if height >= PINNED {
children.extend(apps(app));
diff --git a/crates/tui/src/pages/skills.rs b/crates/tui/src/pages/skills.rs
index 5fa2789e7d..ea9f891c4b 100644
--- a/crates/tui/src/pages/skills.rs
+++ b/crates/tui/src/pages/skills.rs
@@ -447,6 +447,20 @@ pub(crate) mod tests {
let request = app.skills_request().unwrap();
assert_eq!(request.session, "b");
page(&mut app, request, 32, true);
+ frame(&mut app, 80, 28);
+ app.input(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)));
+ app.input(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
+ assert_eq!(app.layer.focused_path(), Some("footer/close"));
+ app.input(Event::Key(KeyEvent::new(
+ KeyCode::BackTab,
+ KeyModifiers::SHIFT,
+ )));
+ assert_eq!(app.layer.focused_path(), Some("list/rows/1"));
+ assert!(
+ app.skills.saved.is_empty(),
+ "moving through a checklist never toggles it"
+ );
+ app.input(Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)));
for (width, height) in [(80, 28), (52, 22)] {
frame(&mut app, width, height);
let first = app.layer.rect("list/rows/0").unwrap();
diff --git a/crates/tui/src/pages/skills/view.rs b/crates/tui/src/pages/skills/view.rs
index 16db418b63..7ad75467bc 100644
--- a/crates/tui/src/pages/skills/view.rs
+++ b/crates/tui/src/pages/skills/view.rs
@@ -101,7 +101,7 @@ pub(crate) fn sheet(app: &App) -> Option<Sheet<Action>> {
.collect();
let height = app.frame_size.map_or(24, |(_, height)| height);
sheet = sheet.body(
- Node::scroll("list", Node::column("rows", nodes))
+ Node::scroll("list", Node::column("rows", nodes).focus_group())
.size(Size::Upto(height.saturating_sub(14).max(3))),
);
}
diff --git a/crates/tui/src/theme/editor/view.rs b/crates/tui/src/theme/editor/view.rs
index afb64d533f..859c9cff45 100644
--- a/crates/tui/src/theme/editor/view.rs
+++ b/crates/tui/src/theme/editor/view.rs
@@ -118,7 +118,7 @@ pub(crate) fn sheet(app: &App) -> Option<Sheet<Action>> {
"label",
vec![(app.i18n.text("theme-swatches"), Tone::Muted)],
),
- Node::column("swatches", swatches),
+ Node::column("swatches", swatches).focus_group(),
Node::row(
"hex",
vec![
@@ -140,7 +140,7 @@ pub(crate) fn sheet(app: &App) -> Option<Sheet<Action>> {
Node::column(
"roles",
vec![
- Node::scroll("list", Node::column("rows", roles))
+ Node::scroll("list", Node::column("rows", roles).focus_group())
.size(Size::Upto(height.saturating_sub(16).clamp(5, 12))),
],
)
diff --git a/crates/tui/src/ui/layout.rs b/crates/tui/src/ui/layout.rs
index 5e1654e53f..4d31bce016 100644
--- a/crates/tui/src/ui/layout.rs
+++ b/crates/tui/src/ui/layout.rs
@@ -42,6 +42,8 @@ pub(super) struct Item<M> {
/// Ordinal of the nearest container: arrow keys along its axis move
/// within it.
pub group: usize,
+ /// An explicit list/tab group; unrelated form controls stay individual.
+ pub tab_group: Option<usize>,
pub axis: Axis,
pub on: On<M>,
pub enabled: bool,
@@ -95,6 +97,7 @@ struct Scope {
clip: Rect,
axis: Axis,
group: usize,
+ tab_group: Option<usize>,
scroller: Option<usize>,
/// Interaction styling of an enclosing interactive node.
style: Option<Style>,
@@ -143,6 +146,7 @@ impl<'a, M> Pass<'a, M> {
clip: area,
axis: Axis::Vertical,
group: 0,
+ tab_group: None,
scroller: None,
style: None,
};
@@ -157,11 +161,16 @@ impl<'a, M> Pass<'a, M> {
enabled,
current,
follow_focus,
+ focus_group,
submit,
hint,
role,
..
} = node;
+ if focus_group {
+ self.groups += 1;
+ scope.tab_group = Some(self.groups);
+ }
let canvas = on.is_none() && matches!(kind, Kind::Slot);
// Scrolled-out items stay registered with an empty visible rectangle:
// the pointer cannot hit them, but the keyboard can still reach them.
@@ -193,6 +202,7 @@ impl<'a, M> Pass<'a, M> {
height: area.height,
scroller: scope.scroller,
group: scope.group,
+ tab_group: scope.tab_group,
axis: scope.axis,
on,
enabled,
diff --git a/crates/tui/src/ui/node.rs b/crates/tui/src/ui/node.rs
index 32efd16ac4..c8a5155ea5 100644
--- a/crates/tui/src/ui/node.rs
+++ b/crates/tui/src/ui/node.rs
@@ -128,6 +128,9 @@ pub struct Node<M> {
/// Keyboard focus arriving here also activates it, for lists whose
/// selection follows the focus.
pub follow_focus: bool,
+ /// Descendants share one Tab stop, with arrows moving inside the group.
+ /// Use for lists and tabs; form fields remain separate stops.
+ pub focus_group: bool,
/// What Enter sends instead of the activation: a list row selects on
/// click or arrow, and Enter commits the sheet's choice.
pub submit: Option<M>,
@@ -146,6 +149,7 @@ impl<M> Node<M> {
enabled: true,
current: false,
follow_focus: false,
+ focus_group: false,
submit: None,
hint: None,
role: None,
@@ -234,6 +238,10 @@ impl<M> Node<M> {
self.follow_focus = true;
self
}
+ pub fn focus_group(mut self) -> Self {
+ self.focus_group = true;
+ self
+ }
pub fn submit(mut self, message: M) -> Self {
self.submit = Some(message);
self
@@ -281,6 +289,7 @@ impl<M> Node<M> {
enabled: self.enabled,
current: self.current,
follow_focus: self.follow_focus,
+ focus_group: self.focus_group,
submit: self.submit.map(f),
hint: self.hint,
role: self.role,
diff --git a/crates/tui/src/ui/surface.rs b/crates/tui/src/ui/surface.rs
index ad7d82168c..f9661b1404 100644
--- a/crates/tui/src/ui/surface.rs
+++ b/crates/tui/src/ui/surface.rs
@@ -452,9 +452,14 @@ impl<M: Clone> Surface<M> {
self.focus_index = if last { usize::MAX } else { 0 };
return;
};
- let mut stops = committed.items.iter().filter(|item| item.enabled);
- let target = if last { stops.last() } else { stops.next() };
- self.focus = target.map(|item| item.id.clone());
+ let stops = self.tab_stops();
+ let target = if last { stops.last() } else { stops.first() };
+ let id = target.map(|index| committed.items[*index].id.clone());
+ if let Some(id) = id {
+ self.move_focus(id);
+ } else {
+ self.focus = None;
+ }
}
/// A shell overlay drawn over this surface hides these cells from the
@@ -741,13 +746,17 @@ impl<M: Clone> Surface<M> {
}
let target = match (key.code, current) {
(KeyCode::Tab | KeyCode::BackTab, _) => {
+ let tabs = self.tab_stops();
+ let current = tabs
+ .iter()
+ .position(|index| Some(&item(*index).id) == self.focus.as_ref());
let backwards =
key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT);
let next = match (current, backwards) {
- (None, false) => stops.first(),
- (None, true) => stops.last(),
- (Some(at), false) => stops.get(at + 1),
- (Some(at), true) => at.checked_sub(1).and_then(|at| stops.get(at)),
+ (None, false) => tabs.first(),
+ (None, true) => tabs.last(),
+ (Some(at), false) => tabs.get(at + 1),
+ (Some(at), true) => at.checked_sub(1).and_then(|at| tabs.get(at)),
};
// Past either end, focus leaves the page for the shell.
match next {
@@ -813,6 +822,42 @@ impl<M: Clone> Surface<M> {
self.focus_item(target)
}
+ /// A list contributes one stop at its position in reading order. Reentry
+ /// keeps the reader's cursor, then prefers a selected or visible item.
+ fn tab_stops(&self) -> Vec<usize> {
+ let Some(committed) = &self.committed else {
+ return vec![];
+ };
+ let priority = |item: &Item<M>| {
+ (
+ Some(&item.id) == self.focus.as_ref(),
+ self.recent.iter().rposition(|id| *id == item.id),
+ item.current,
+ !item.rect.is_empty(),
+ )
+ };
+ let mut stops = vec![];
+ let mut groups = HashMap::new();
+ for (index, item) in committed
+ .items
+ .iter()
+ .enumerate()
+ .filter(|(_, item)| item.enabled)
+ {
+ if let Some(group) = item.tab_group {
+ if let Some(at) = groups.get(&group).copied() {
+ if priority(item) > priority(&committed.items[stops[at]]) {
+ stops[at] = index;
+ }
+ continue;
+ }
+ groups.insert(group, stops.len());
+ }
+ stops.push(index);
+ }
+ stops
+ }
+
/// Next stop along the focused item's own group axis.
fn along(&self, stops: &[usize], from: usize, code: KeyCode) -> Option<usize> {
let items = &self.committed.as_ref()?.items;
@@ -1190,6 +1235,74 @@ mod tests {
);
}
+ #[test]
+ fn tab_crosses_long_lists_and_returns_to_the_cursor_without_skipping_fields() {
+ let tree = |reverse: bool, removed: Option<usize>| {
+ let mut rows: Vec<_> = (0..200)
+ .filter(|index| Some(*index) != removed)
+ .map(|index| {
+ Node::text(
+ index.to_string(),
+ vec![(format!("Item {index}"), Tone::Normal)],
+ )
+ .on(On::Activate(Message::Pick(index)))
+ .current(index == 150)
+ .enabled(index != 151)
+ })
+ .collect();
+ if reverse {
+ rows.reverse();
+ }
+ Node::column(
+ "root",
+ vec![
+ Node::slot("before", 1).on(On::Activate(Message::Choose("before"))),
+ Node::scroll("list", Node::column("rows", rows).focus_group())
+ .size(Size::Fixed(5)),
+ Node::slot("after", 1).on(On::Activate(Message::Choose("after"))),
+ Node::button("save", "Save".into(), Role::Primary)
+ .on(On::Activate(Message::Choose("save"))),
+ ],
+ )
+ .map(&|message| message)
+ };
+ let mut surface = Surface::default();
+ draw(&mut surface, 40, 10, tree(false, None));
+ assert_eq!(surface.focused(), Some("root/before"));
+ assert!(surface.input(&key(KeyCode::Tab)).message.is_none());
+ assert_eq!(surface.focused(), Some("root/list/rows/150"));
+ draw(&mut surface, 40, 10, tree(false, None));
+ assert!(!surface.rect("root/list/rows/150").unwrap().is_empty());
+ surface.input(&key(KeyCode::Down));
+ assert_eq!(surface.focused(), Some("root/list/rows/152"));
+ surface.input(&key(KeyCode::Tab));
+ assert_eq!(surface.focused(), Some("root/after"));
+ surface.input(&key(KeyCode::BackTab));
+ assert_eq!(surface.focused(), Some("root/list/rows/152"));
+ surface.input(&key(KeyCode::Tab));
+ surface.input(&key(KeyCode::Tab));
+ assert_eq!(surface.focused(), Some("root/save"));
+ assert!(!surface.input(&key(KeyCode::Tab)).consumed);
+ surface.input(&key(KeyCode::BackTab));
+ draw(&mut surface, 40, 10, tree(true, None));
+ surface.input(&key(KeyCode::BackTab));
+ assert_eq!(
+ surface.focused(),
+ Some("root/list/rows/152"),
+ "identity survives reordering"
+ );
+ surface.input(&key(KeyCode::Tab));
+ draw(&mut surface, 40, 10, tree(true, Some(152)));
+ surface.input(&key(KeyCode::BackTab));
+ assert_eq!(
+ surface.focused(),
+ Some("root/list/rows/150"),
+ "a removed cursor falls back within its group"
+ );
+ surface.input(&key(KeyCode::BackTab));
+ assert_eq!(surface.focused(), Some("root/before"));
+ }
+
#[test]
fn chooser_is_modal_dismisses_outside_without_passthrough_and_emits_choices() {
let mut surface = Surface::default();
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index 14e9ed15a0..a2edee461d 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -156,6 +156,8 @@ Host plugins use `ctx.tui.app(name, { read, submit, recover? }, descriptor)` to
`read(route, cx)` returns a View v4 body built with `ctx.tui` (columns, rows, splits, tabs, text, Markdown, controls and fields). The SDK adds the version. Stable sibling keys preserve focus and editing state; `cx.t(en, zhCN, zhTW)` selects the reader's language. The shell owns layout, local typing, scrolling, confirmation and draft recovery. Plugins use semantic tones and never emit terminal escapes.
+Tabs and columns of items each contribute one keyboard Tab stop; arrow keys move inside them. Reentering a group restores its last focused item. A list column may include decorative text, Markdown, code, rules or progress; put fields and buttons outside it so each remains reachable with Tab. Focus changes alone never submit an action or follow a plugin route.
+
`submit({ route, revision, action, fields, grant }, cx)` applies an explicit user action and returns `applied`, `conflict`, `rejected`, or `consent`. Use the revision for storage CAS and enforce domain authority in the Host. Declare an action's `recovery` route only when `recover(route, cx)` can look up its durable outcome; the shell never blindly replays an uncertain write.
For live updates, `const changed = await ctx.tui.changes('changed')` registers a stream. Set `changes: 'changed'` on the descriptor and call `changed()` after committing data. Invalidation is coalesced; the shell rereads clean views and preserves edited drafts. Subscriptions and controls retire with their registration.
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index aba5377d14..0c452309b9 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -160,6 +160,8 @@ Host 插件通过 `ctx.tui.app(name, { read, submit, recover? }, descriptor)`
`read(route, cx)` 返回用 `ctx.tui` 构造的 View v4 内容,包括列、行、分栏、标签、文本、Markdown、控件和字段,SDK 自动添加版本。稳定的同级 key 保留焦点与编辑状态;`cx.t(en, zhCN, zhTW)` 选择当前语言。外壳负责布局、本地输入、滚动、确认与草稿恢复;插件使用语义颜色,不输出终端转义序列。
+标签组和由 item 组成的列各占一个 Tab 停靠点,方向键在组内移动,重新进入时恢复上次焦点。列表列可包含文本、Markdown、代码、分隔线和进度;字段与按钮放在列表外,保持逐个 Tab 可达。仅移动焦点不会提交动作或进入插件路由。
+
`submit({ route, revision, action, fields, grant }, cx)` 执行明确的用户操作,返回 `applied`、`conflict`、`rejected` 或 `consent`。使用 revision 做存储 CAS,并在 Host 检查领域权限。只有 `recover(route, cx)` 能查询持久结果时才声明 action 的 `recovery` 路由;外壳不会盲目重放结果未知的写入。
实时刷新使用 `const changed = await ctx.tui.changes('changed')` 注册流,在 descriptor 设置 `changes: 'changed'`,数据提交后调用 `changed()`。失效通知会合并,外壳重新读取干净视图并保留已有草稿;订阅与控件随注册退休。
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org