use std::borrow::Cow;
use crate::menu::Menu;
use crate::menu::arg::Arg;
use crate::style::Style;
use crate::ui::layout::opts;
use crate::ui::{self, UiTree, layout_line, layout_span, repeat_chars};
use crate::{
app::State,
config::{Config, ReadOnly},
ops::Op,
};
use itertools::Itertools;
use unicode_width::UnicodeWidthStr;
#[derive(Clone)]
enum MenuValue<'a> {
Text(Cow<'a, str>),
Arg(&'a Arg),
}
pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, size: (u16, u16)) {
let (width, height) = (size.0 as usize, size.1);
let Some(ref pending) = state.pending_menu else {
return;
};
if pending.is_hidden {
return;
}
if state.picker.is_some() {
return;
}
let config = &state.config;
let item = state.screens.last().unwrap().get_selected_item();
let style = &config.style;
let arg_binds = config.bindings.arg_list(pending).collect::<Vec<_>>();
let (target_binds, non_target_binds): (Vec<_>, Vec<_>) = config
.bindings
.list(&pending.menu)
.filter(|keybind| !(state.read_only && keybind.op.writes()))
.partition(|keybind| keybind.op.clone().implementation().is_target_op());
let target_binds: Vec<_> = target_binds
.into_iter()
.filter(|keybind| {
keybind
.op
.clone()
.implementation()
.get_action(&item.data)
.is_some()
})
.collect();
let (menu_binds, non_menu_binds): (Vec<_>, Vec<_>) = non_target_binds
.into_iter()
.chunk_by(|bind| &bind.op)
.into_iter()
.map(|(op, binds)| {
let binds: Vec<_> = binds.collect();
(op, binds)
})
.partition(|(op, _binds)| matches!(op, Op::OpenMenu(_)));
let feature_binds: Vec<_> = if matches!(pending.menu, Menu::Root | Menu::Help) {
state
.feature_keys
.iter()
.map(|(feature, key)| {
(
key.to_string().into(),
MenuValue::Text(feature.as_str().into()),
)
})
.collect()
} else {
vec![]
};
let separator_style = Style::from(&style.separator);
let sorted = matches!(pending.menu, Menu::Root | Menu::Help);
let read_only_notice = (state.read_only && matches!(pending.menu, Menu::Root | Menu::Help))
.then(|| {
let first = match config.general.read_only {
ReadOnly::Auto => "Read-only (.jj found):",
ReadOnly::On | ReadOnly::Off => "Read-only:",
};
format!("{first} commands that change the repository are hidden")
});
let heading = |text: Cow<'a, str>| Line::Heading(text, Style::from(&style.menu.heading));
let binds = |rows: Vec<(Cow<'a, str>, MenuValue<'a>)>| rows.into_iter().map(Line::Bind);
let mut columns: Vec<Vec<Line<'a>>> = vec![];
if !non_menu_binds.is_empty() {
let title = match pending.menu {
Menu::Root | Menu::Help => "Commands".into(),
menu => menu.to_string(),
};
let rows = non_menu_binds
.into_iter()
.map(|(op, binds)| {
(
binds.iter().map(|bind| bind.raw.as_str()).join("/").into(),
MenuValue::Text(op.clone().implementation().display(state).into()),
)
})
.collect();
columns.push(
std::iter::once(heading(title.into()))
.chain(binds(in_order(sorted, rows)))
.collect(),
);
}
if !menu_binds.is_empty() || !feature_binds.is_empty() {
let mut column = vec![];
if !menu_binds.is_empty() {
let rows = menu_binds
.into_iter()
.map(|(op, binds)| {
let Op::OpenMenu(menu) = op else {
unreachable!();
};
(
binds.iter().map(|bind| bind.raw.as_str()).join("/").into(),
MenuValue::Text(menu.to_string().into()),
)
})
.collect();
column.push(heading("Submenus".into()));
column.extend(binds(in_order(sorted, rows)));
}
if !feature_binds.is_empty() {
if !column.is_empty() {
column.push(Line::Blank);
}
column.push(heading("Hotkeys".into()));
column.extend(binds(in_order(sorted, feature_binds)));
}
columns.push(column);
}
let mut column = vec![];
if !target_binds.is_empty() {
column.push(heading(item.data.kind().into()));
column.extend(binds(
target_binds
.into_iter()
.map(|bind| {
(
bind.raw.as_str().into(),
MenuValue::Text(bind.op.clone().implementation().display(state).into()),
)
})
.collect(),
));
}
if let Some(notice) = read_only_notice {
if !column.is_empty() {
column.push(Line::Blank);
}
column.push(Line::Text(notice.into(), Style::from(&style.error_msg)));
}
if !arg_binds.is_empty() {
column.push(heading("Arguments".into()));
column.extend(binds(
arg_binds
.into_iter()
.map(|bind| {
let Op::ToggleArg(name) = &bind.op else {
unreachable!();
};
let arg = pending.args.get(name.as_str()).unwrap();
(bind.raw.as_str().into(), MenuValue::Arg(arg))
})
.collect(),
));
}
if !column.is_empty() {
columns.push(column);
}
let max_lines = (height as usize).saturating_sub(2).max(1);
let (shown, cut) = match fit(&columns, max_lines, width) {
(_, 0) => fit(&columns, max_lines, width),
_ => fit(&columns, max_lines.saturating_sub(1).max(1), width),
};
layout.col(opts(), |layout| {
ui::repeat_chars(layout, width, ui::DASHES, separator_style);
layout.row(opts().gap(3).pad(1), |layout| {
for lines in shown {
layout_lines(layout, config, lines);
}
});
if cut > 0 {
layout_line(
layout,
format!(" … {cut} more: make the window larger").into(),
Style::from(&style.menu.heading),
);
}
});
}
#[derive(Clone)]
enum Line<'a> {
Heading(Cow<'a, str>, Style),
Text(Cow<'a, str>, Style),
Bind((Cow<'a, str>, MenuValue<'a>)),
Blank,
}
impl Line<'_> {
fn width(&self, key_width: usize) -> usize {
match self {
Line::Heading(text, _) | Line::Text(text, _) => text.width(),
Line::Bind((_, value)) => key_width + value.width(),
Line::Blank => 0,
}
}
}
impl MenuValue<'_> {
fn width(&self) -> usize {
match self {
MenuValue::Text(text) => text.width(),
MenuValue::Arg(arg) => arg.display.width() + arg.get_cli_token().width() + 3,
}
}
}
fn key_widths(lines: &[Line]) -> Vec<usize> {
let mut widths = vec![0; lines.len()];
let mut start = 0;
for end in 0..=lines.len() {
if end == lines.len() || !matches!(lines[end], Line::Bind(_)) {
let width = lines[start..end]
.iter()
.filter_map(|line| match line {
Line::Bind((key, _)) => Some(key.width() + 1),
_ => None,
})
.max()
.unwrap_or(0);
widths[start..end].fill(width);
start = end + 1;
}
}
widths
}
const MIN_WRAPPED_WIDTH: usize = 30;
fn fit<'a>(
columns: &[Vec<Line<'a>>],
max_lines: usize,
width: usize,
) -> (Vec<Vec<Line<'a>>>, usize) {
let mut sub_columns: Vec<Vec<Line>> = vec![];
for column in columns {
let mut current: Vec<Line> = vec![];
for line in column.iter().cloned() {
if current.len() == max_lines {
let carried = match current.last() {
Some(Line::Heading(..)) if current.len() > 1 => current.pop(),
_ => None,
};
sub_columns.push(std::mem::take(&mut current));
current.push(carried.unwrap_or(Line::Blank));
}
if current.len() == 1
&& matches!(current[0], Line::Blank)
&& matches!(line, Line::Blank)
{
continue;
}
current.push(line);
}
if !current.is_empty() {
sub_columns.push(current);
}
}
let mut used = 2;
let mut shown = vec![];
let mut cut = 0;
for lines in sub_columns {
let keys = key_widths(&lines);
let needed = lines
.iter()
.zip(keys)
.map(|(line, keys)| line.width(keys))
.max()
.unwrap_or(0);
let gap = if shown.is_empty() { 0 } else { 3 };
if cut == 0 && used + gap + needed.min(MIN_WRAPPED_WIDTH) <= width {
used += gap + needed;
shown.push(lines);
} else {
cut += lines
.iter()
.filter(|line| matches!(line, Line::Bind(_)))
.count();
}
}
(shown, cut)
}
fn layout_lines<'a>(layout: &mut UiTree<'a>, config: &Config, lines: Vec<Line<'a>>) {
const SPACES: &str = " ";
let key_style = Style::from(&config.style.menu.key);
let keys = key_widths(&lines);
layout.col(opts(), |layout| {
for (line, keys) in lines.into_iter().zip(keys) {
match line {
Line::Heading(text, style) | Line::Text(text, style) => {
layout_line(layout, text, style)
}
Line::Blank => layout_line(layout, " ".into(), Style::new()),
Line::Bind((key, value)) => {
let padding = keys - key.width();
layout.row(opts(), |layout| {
layout_line(layout, key, key_style);
repeat_chars(layout, padding, SPACES, Style::new());
layout.row(opts(), |layout| layout_value(layout, config, value));
});
}
}
}
});
}
fn layout_value<'a>(layout: &mut UiTree<'a>, config: &Config, value: MenuValue<'a>) {
match value {
MenuValue::Text(text) => layout_span(layout, (text, Style::new())),
MenuValue::Arg(arg) => {
layout_span(layout, (arg.display.into(), Style::new()));
layout_span(layout, (" (".into(), Style::new()));
layout_span(
layout,
(
arg.get_cli_token().into(),
if arg.is_active() {
Style::from(&config.style.menu.active_arg)
} else {
Style::from(&config.style.menu.inactive_arg)
},
),
);
layout_span(layout, (")".into(), Style::new()));
}
}
}
fn in_order<'a>(
sorted: bool,
mut rows: Vec<(Cow<'a, str>, MenuValue<'a>)>,
) -> Vec<(Cow<'a, str>, MenuValue<'a>)> {
if sorted {
rows.sort_by_key(|(key, _)| (key.to_lowercase(), key.chars().any(char::is_uppercase)));
}
rows
}