Make the keymap action menu responsive (#35375)

## What changed

- Stack action descriptions below their labels when the keymap action menu is too narrow for a readable description column.
- Keep descriptions column-aligned at wider terminal widths.
- Show a dash instead of a number beside the disabled remove-binding action.

## Testing

- Add snapshot coverage for the action menu at 48, 64, and 96 columns.

GitOrigin-RevId: 25b97fd2d8764563e0a0c36d436711f2429093c4
This commit is contained in:
Felipe Coury
2026-07-25 19:28:57 +00:00
committed by copyberry
parent 322d5b96cf
commit 20dafe201d
6 changed files with 386 additions and 132 deletions
@@ -34,6 +34,7 @@ use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height_with_col_width_mode;
use super::selection_popup_common::render_rows_single_line_with_col_width_mode;
use super::selection_popup_common::render_rows_with_col_width_mode;
pub(crate) use super::selection_row_layout::SelectionDescriptionLayout;
use super::selection_tabs::SelectionTab;
use super::selection_tabs::render_tab_bar;
use super::selection_tabs::tab_bar_height;
@@ -145,6 +146,8 @@ pub(crate) struct SelectionItem {
pub dismiss_parent_on_child_accept: bool,
pub search_value: Option<String>,
pub disabled_reason: Option<String>,
/// Optional marker rendered in place of the number for a disabled row.
pub disabled_gutter_marker: Option<&'static str>,
}
/// Construction-time configuration for [`ListSelectionView`].
@@ -158,6 +161,8 @@ pub(crate) struct SelectionItem {
/// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls
/// `Fixed` used a fixed 30/70 split between columns
/// `row_display` controls whether rows can wrap or stay single-line with ellipsis truncation
/// `description_layout` optionally moves descriptions below labels when their
/// column would become too narrow.
pub(crate) struct SelectionViewParams {
pub view_id: Option<&'static str>,
pub title: Option<String>,
@@ -172,6 +177,7 @@ pub(crate) struct SelectionViewParams {
pub search_placeholder: Option<String>,
pub col_width_mode: ColumnWidthMode,
pub row_display: SelectionRowDisplay,
pub description_layout: SelectionDescriptionLayout,
/// Rendered left-column width to use for auto-sized rows.
pub name_column_width: Option<usize>,
pub header: Box<dyn Renderable>,
@@ -223,6 +229,7 @@ impl Default for SelectionViewParams {
search_placeholder: None,
col_width_mode: ColumnWidthMode::AutoVisible,
row_display: SelectionRowDisplay::Wrapped,
description_layout: SelectionDescriptionLayout::Columns,
name_column_width: None,
header: Box::new(()),
initial_selected_idx: None,
@@ -260,6 +267,7 @@ pub(crate) struct ListSelectionView {
search_placeholder: Option<String>,
col_width_mode: ColumnWidthMode,
row_display: SelectionRowDisplay,
description_layout: SelectionDescriptionLayout,
name_column_width: Option<usize>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
@@ -394,6 +402,7 @@ impl ListSelectionView {
},
col_width_mode: params.col_width_mode,
row_display: params.row_display,
description_layout: params.description_layout,
name_column_width: params.name_column_width,
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
@@ -581,7 +590,14 @@ impl ListSelectionView {
// numbers be used for the search query).
format!("{prefix} ")
} else if is_disabled {
format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2))
if let Some(disabled_gutter_marker) = item.disabled_gutter_marker {
let marker_width = UnicodeWidthStr::width(disabled_gutter_marker);
let marker_padding =
" ".repeat(enabled_row_number_width.saturating_sub(marker_width));
format!("{prefix} {marker_padding}{disabled_gutter_marker} ")
} else {
format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2))
}
} else {
enabled_row_number += 1;
let n = enabled_row_number;
@@ -1115,7 +1131,8 @@ impl Renderable for ListSelectionView {
// Measure wrapped height for up to MAX_POPUP_ROWS items.
let rows = self.build_rows();
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width);
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width)
.with_description_layout(self.description_layout);
let rows_height = match self.row_display {
SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode(
&rows,
@@ -1193,7 +1210,8 @@ impl Renderable for ListSelectionView {
let header_height = header.desired_height(inner_width);
let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width);
let rows = self.build_rows();
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width);
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width)
.with_description_layout(self.description_layout);
let rows_height = match self.row_display {
SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode(
&rows,
+2
View File
@@ -120,6 +120,7 @@ pub(crate) use footer::goal_status_indicator_line;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::ListSelectionView;
pub(crate) use list_selection_view::OnSelectionChangedCallback;
pub(crate) use list_selection_view::SelectionDescriptionLayout;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
pub(crate) use list_selection_view::SelectionViewParams;
@@ -152,6 +153,7 @@ mod pending_thread_approvals;
pub(crate) mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod selection_row_layout;
mod selection_tabs;
mod textarea;
mod unified_exec_footer;
@@ -7,8 +7,6 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
@@ -19,6 +17,10 @@ use crate::style::accent_style;
use crate::style::user_message_style;
use super::scroll_state::ScrollState;
use super::selection_row_layout::SelectionDescriptionLayout;
use super::selection_row_layout::build_full_line;
use super::selection_row_layout::line_to_owned;
use super::selection_row_layout::wrap_stacked_row;
/// Render-ready representation of one row in a selection popup.
///
@@ -59,6 +61,7 @@ pub(crate) enum ColumnWidthMode {
pub(crate) struct ColumnWidthConfig {
pub mode: ColumnWidthMode,
pub name_column_width: Option<usize>,
pub description_layout: SelectionDescriptionLayout,
}
impl ColumnWidthConfig {
@@ -66,8 +69,17 @@ impl ColumnWidthConfig {
Self {
mode,
name_column_width,
description_layout: SelectionDescriptionLayout::Columns,
}
}
pub(crate) const fn with_description_layout(
mut self,
description_layout: SelectionDescriptionLayout,
) -> Self {
self.description_layout = description_layout;
self
}
}
// Fixed split used by explicitly fixed column mode: 30% label, 70%
@@ -121,21 +133,6 @@ pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'
word_wrap_line(line, opts)
}
fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
fn compute_desc_col(
rows_all: &[GenericDisplayRow],
start_idx: usize,
@@ -287,11 +284,16 @@ fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) ->
out
}
fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
fn wrap_standard_row(
row: &GenericDisplayRow,
desc_col: usize,
width: u16,
description_layout: SelectionDescriptionLayout,
) -> Vec<Line<'static>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let full_line = build_full_line(row, desc_col);
let full_line = build_full_line(row, desc_col, description_layout);
let continuation_indent = wrap_indent(row, desc_col, width);
let options = RtOptions::new(width.max(1) as usize)
.initial_indent(Line::from(""))
@@ -302,7 +304,15 @@ fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Ve
.collect()
}
fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
fn wrap_row_lines(
row: &GenericDisplayRow,
desc_col: usize,
width: u16,
description_layout: SelectionDescriptionLayout,
) -> Vec<Line<'static>> {
if description_layout.should_stack(width, desc_col) {
return wrap_stacked_row(row, width);
}
if should_wrap_name_in_column(row) {
let wrapped = wrap_two_column_row(row, desc_col, width);
if !wrapped.is_empty() {
@@ -310,7 +320,7 @@ fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<L
}
}
wrap_standard_row(row, desc_col, width)
wrap_standard_row(row, desc_col, width, description_layout)
}
fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disabled: bool) {
@@ -353,23 +363,31 @@ fn compute_item_window_start(
start_idx
}
#[derive(Clone, Copy)]
struct WrappedViewport {
width: u16,
height: u16,
description_layout: SelectionDescriptionLayout,
}
fn is_selected_visible_in_wrapped_viewport(
rows_all: &[GenericDisplayRow],
start_idx: usize,
max_items: usize,
selected_idx: usize,
desc_col: usize,
width: u16,
viewport_height: u16,
viewport: WrappedViewport,
) -> bool {
if viewport_height == 0 {
if viewport.height == 0 {
return false;
}
let mut used_lines = 0usize;
let viewport_height = viewport_height as usize;
let viewport_height = viewport.height as usize;
for (idx, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
let row_lines = wrap_row_lines(row, desc_col, width).len().max(1);
let row_lines = wrap_row_lines(row, desc_col, viewport.width, viewport.description_layout)
.len()
.max(1);
// Keep rendering semantics in sync: always show the first row, even if
// it overflows the viewport.
if used_lines > 0 && used_lines.saturating_add(row_lines) > viewport_height {
@@ -414,8 +432,11 @@ fn adjust_start_for_wrapped_selection_visibility(
max_items,
sel,
desc_col,
width,
viewport_height,
WrappedViewport {
width,
height: viewport_height,
description_layout: column_width.description_layout,
},
) {
break;
}
@@ -424,92 +445,6 @@ fn adjust_start_for_wrapped_selection_visibility(
start_idx
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
/// dims the description.
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let combined_description = match (&row.description, &row.disabled_reason) {
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
(Some(desc), None) => Some(desc.clone()),
(None, Some(reason)) => Some(format!("disabled: {reason}")),
(None, None) => None,
};
// Enforce single-line name: allow at most desc_col - 2 cells for name,
// reserving two spaces before the description column.
let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width();
let name_limit = combined_description
.as_ref()
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
.unwrap_or(usize::MAX);
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
let mut used_width = 0usize;
let mut truncated = false;
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
}
} else {
for ch in row.name.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
name_spans.push(ch.to_string().into());
}
}
if truncated {
// If there is at least one cell available, add an ellipsis.
// When name_limit is 0, we still show an ellipsis to indicate truncation.
name_spans.push("…".into());
}
if row.disabled_reason.is_some() {
name_spans.push(" (disabled)".dim());
}
let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = row.name_prefix_spans.clone();
full_spans.extend(name_spans);
if let Some(display_shortcut) = row.display_shortcut {
full_spans.push(" (".into());
full_spans.push(display_shortcut.into());
full_spans.push(")".into());
}
if let Some(desc) = combined_description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
if gap > 0 {
full_spans.push(" ".repeat(gap).into());
}
full_spans.push(desc.clone().dim());
}
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
full_spans.push(" ".into());
full_spans.push(tag.to_string().dim());
}
Line::from(full_spans)
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// Returns the number of terminal lines actually rendered (including the
@@ -566,7 +501,8 @@ fn render_rows_inner(
break;
}
let mut wrapped = wrap_row_lines(row, desc_col, area.width);
let mut wrapped =
wrap_row_lines(row, desc_col, area.width, column_width.description_layout);
apply_row_state_style(
&mut wrapped,
Some(i) == state.selected_idx && !row.is_disabled,
@@ -721,7 +657,7 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
break;
}
let mut full_line = build_full_line(row, desc_col);
let mut full_line = build_full_line(row, desc_col, column_width.description_layout);
if Some(i) == state.selected_idx && !row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = accent_style();
@@ -828,7 +764,13 @@ fn measure_rows_height_inner(
.take(visible_items)
.map(|(_, r)| r)
{
let wrapped_lines = wrap_row_lines(row, desc_col, content_width).len();
let wrapped_lines = wrap_row_lines(
row,
desc_col,
content_width,
column_width.description_layout,
)
.len();
total = total.saturating_add(wrapped_lines as u16);
}
total.max(1)
@@ -0,0 +1,203 @@
use std::borrow::Cow;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use super::selection_popup_common::GenericDisplayRow;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
/// Controls whether selection-row descriptions stay in a column or move below
/// their labels when the description column becomes too narrow to read.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum SelectionDescriptionLayout {
#[default]
Columns,
StackBelowWhenNarrow {
min_description_width: u16,
},
}
impl SelectionDescriptionLayout {
pub(super) fn should_stack(self, width: u16, desc_col: usize) -> bool {
let Self::StackBelowWhenNarrow {
min_description_width,
} = self
else {
return false;
};
let desc_col = desc_col.min(width as usize) as u16;
width.saturating_sub(desc_col) < min_description_width
}
}
pub(super) fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
fn combined_description(
row: &GenericDisplayRow,
description_layout: SelectionDescriptionLayout,
) -> Option<String> {
match (&row.description, &row.disabled_reason) {
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
(Some(desc), None) => Some(desc.clone()),
(None, Some(reason))
if matches!(
description_layout,
SelectionDescriptionLayout::StackBelowWhenNarrow { .. }
) =>
{
Some(reason.clone())
}
(None, Some(reason)) => Some(format!("disabled: {reason}")),
(None, None) => None,
}
}
fn stacked_description(row: &GenericDisplayRow) -> Option<String> {
match (&row.description, &row.disabled_reason) {
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
(Some(desc), None) => Some(desc.clone()),
(None, Some(reason)) => Some(reason.clone()),
(None, None) => None,
}
}
fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec<Span<'static>> {
let mut name_spans = Vec::with_capacity(row.name.len());
let mut used_width = 0usize;
let mut truncated = false;
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
}
} else {
for ch in row.name.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
name_spans.push(ch.to_string().into());
}
}
if truncated {
name_spans.push("…".into());
}
if row.disabled_reason.is_some() {
name_spans.push(" (disabled)".dim());
}
name_spans
}
fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
if let Some(display_shortcut) = row.display_shortcut {
spans.push(" (".into());
spans.push(display_shortcut.into());
spans.push(")".into());
}
}
fn append_category_tag(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
spans.push(" ".into());
spans.push(tag.to_string().dim());
}
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`.
pub(super) fn build_full_line(
row: &GenericDisplayRow,
desc_col: usize,
description_layout: SelectionDescriptionLayout,
) -> Line<'static> {
let description = combined_description(row, description_layout);
let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width();
let name_limit = description
.as_ref()
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
.unwrap_or(usize::MAX);
let name_spans = build_name_spans(row, name_limit);
let name_width = name_prefix_width + Line::from(name_spans.clone()).width();
let mut spans = row.name_prefix_spans.clone();
spans.extend(name_spans);
append_shortcut(row, &mut spans);
if let Some(description) = description {
let gap = desc_col.saturating_sub(name_width);
if gap > 0 {
spans.push(" ".repeat(gap).into());
}
spans.push(description.dim());
}
append_category_tag(row, &mut spans);
Line::from(spans)
}
/// Render a row as a full-width label followed by an indented description.
pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec<Line<'static>> {
let width = width.max(1);
let prefix_width = Line::from(row.name_prefix_spans.clone())
.width()
.min(width.saturating_sub(1) as usize);
let indent = " ".repeat(prefix_width);
let mut label_spans = row.name_prefix_spans.clone();
label_spans.extend(build_name_spans(row, usize::MAX));
append_shortcut(row, &mut label_spans);
append_category_tag(row, &mut label_spans);
let label = Line::from(label_spans);
let label_options = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(indent.clone()));
let mut lines = word_wrap_line(&label, label_options)
.into_iter()
.map(line_to_owned)
.collect::<Vec<_>>();
if let Some(description) = stacked_description(row) {
let description = Line::from(description.dim());
let description_options = RtOptions::new(width as usize)
.initial_indent(Line::from(indent.clone()))
.subsequent_indent(Line::from(indent));
lines.extend(
word_wrap_line(&description, description_options)
.into_iter()
.map(line_to_owned),
);
}
lines
}
+40 -12
View File
@@ -52,6 +52,7 @@ use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionDescriptionLayout;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
@@ -69,6 +70,7 @@ use debug::KeymapDebugView;
pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu";
pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu";
const KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH: u16 = 24;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum KeymapEditOutcome {
@@ -166,8 +168,8 @@ pub(crate) fn build_keymap_action_menu_params(
let description = descriptor
.map(|descriptor| descriptor.description)
.unwrap_or("Configure this shortcut.");
let remove_disabled_reason = (!custom_binding)
.then(|| "There is no custom root binding for this action to remove.".to_string());
let remove_disabled_reason =
(!custom_binding).then(|| "No custom root override to remove.".to_string());
let label = action_label(&action);
let remove_context = context.clone();
let remove_action = action.clone();
@@ -263,15 +265,11 @@ pub(crate) fn build_keymap_action_menu_params(
}
items.push(SelectionItem {
name: "Remove custom binding".to_string(),
description: Some(if custom_binding {
"Restore the default keymap binding.".to_string()
} else {
"No root override to remove.".to_string()
}),
selected_description: Some(
"Delete the root override and use the default keymap again.".to_string(),
),
description: custom_binding.then(|| "Restore the default keymap binding.".to_string()),
selected_description: custom_binding
.then(|| "Delete the root override and use the default keymap again.".to_string()),
disabled_reason: remove_disabled_reason,
disabled_gutter_marker: Some("–"),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::KeymapCleared {
context: remove_context.clone(),
@@ -297,7 +295,10 @@ pub(crate) fn build_keymap_action_menu_params(
])),
footer_hint: Some(keymap_action_menu_hint_line()),
items,
col_width_mode: ColumnWidthMode::Fixed,
col_width_mode: ColumnWidthMode::AutoAllRows,
description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow {
min_description_width: KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH,
},
..Default::default()
}
}
@@ -1297,6 +1298,33 @@ mod tests {
assert_snapshot!("keymap_action_menu", snapshot);
}
#[test]
fn action_menu_responsive_render_snapshot() {
let runtime = RuntimeKeymap::defaults();
let render_at = |width| {
let params = build_keymap_action_menu_params(
"global".to_string(),
"open_transcript".to_string(),
&runtime,
&TuiKeymap::default(),
);
render_picker(params, width)
};
let snapshot = [
"48 columns:",
&render_at(/*width*/ 48),
"",
"64 columns:",
&render_at(/*width*/ 64),
"",
"96 columns:",
&render_at(/*width*/ 96),
]
.join("\n");
assert_snapshot!("keymap_action_menu_responsive", snapshot);
}
#[test]
fn action_menu_disables_clear_when_action_has_no_custom_binding() {
let runtime = RuntimeKeymap::defaults();
@@ -1314,7 +1342,7 @@ mod tests {
let back = selection_item(&params, "Back to shortcuts");
assert_eq!(
remove.disabled_reason.as_deref(),
Some("There is no custom root binding for this action to remove.")
Some("No custom root override to remove.")
);
assert!(
!replace.dismiss_on_select,
@@ -0,0 +1,61 @@
---
source: tui/src/keymap_setup.rs
expression: snapshot
---
48 columns:
Edit Shortcut
Open Transcript · Global
Current ctrl-t · Default keymap
Config `tui.keymap.global.open_transcript`
Open the transcript overlay.
› 1. Replace binding
Capture one key and replace `ctrl-t`.
2. Add alternate binding
Keep the current binding and add another
key.
– Remove custom binding (disabled)
No custom root override to remove.
3. Back to shortcuts
Return to the shortcut list.
Changes write the root `tui.keymap.*`
override.
enter select · esc back
64 columns:
Edit Shortcut
Open Transcript · Global
Current ctrl-t · Default keymap
Config `tui.keymap.global.open_transcript`
Open the transcript overlay.
› 1. Replace binding
Capture one key and replace `ctrl-t`.
2. Add alternate binding
Keep the current binding and add another key.
– Remove custom binding (disabled)
No custom root override to remove.
3. Back to shortcuts
Return to the shortcut list.
Changes write the root `tui.keymap.*` override.
enter select · esc back
96 columns:
Edit Shortcut
Open Transcript · Global
Current ctrl-t · Default keymap
Config `tui.keymap.global.open_transcript`
Open the transcript overlay.
› 1. Replace binding Capture one key and replace `ctrl-t`.
2. Add alternate binding Keep the current binding and add another key.
– Remove custom binding (disabled) No custom root override to remove.
3. Back to shortcuts Return to the shortcut list.
Changes write the root `tui.keymap.*` override.
enter select · esc back