mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¶ms, "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,
|
||||
|
||||
+61
@@ -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
|
||||
Reference in New Issue
Block a user