feat(tui): vim modal editing in composer (#659)
This commit is contained in:
@@ -39,6 +39,10 @@ pub struct Settings {
|
||||
pub composer_density: String,
|
||||
/// Show a border around the composer input area
|
||||
pub composer_border: bool,
|
||||
/// Composer editing mode: "normal" (default) or "vim" for modal editing.
|
||||
/// When set to "vim" the composer starts in Normal mode; press i/a/o to
|
||||
/// enter Insert mode and Esc to return to Normal.
|
||||
pub composer_vim_mode: String,
|
||||
/// Transcript spacing rhythm: compact, comfortable, spacious
|
||||
pub transcript_spacing: String,
|
||||
/// Default mode: "agent", "plan", "yolo"
|
||||
@@ -79,6 +83,7 @@ impl Default for Settings {
|
||||
locale: "auto".to_string(),
|
||||
composer_density: "comfortable".to_string(),
|
||||
composer_border: true,
|
||||
composer_vim_mode: "normal".to_string(),
|
||||
transcript_spacing: "comfortable".to_string(),
|
||||
default_mode: "agent".to_string(),
|
||||
sidebar_width_percent: 28,
|
||||
@@ -212,6 +217,15 @@ impl Settings {
|
||||
"composer_border" | "border" => {
|
||||
self.composer_border = parse_bool(value)?;
|
||||
}
|
||||
"composer_vim_mode" | "vim_mode" | "vim" => {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
if !["vim", "normal"].contains(&normalized.as_str()) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid composer vim mode '{value}'. Expected: normal, vim."
|
||||
);
|
||||
}
|
||||
self.composer_vim_mode = normalized;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
let normalized = normalize_transcript_spacing(value);
|
||||
if !["compact", "comfortable", "spacious"].contains(&normalized) {
|
||||
@@ -314,6 +328,7 @@ impl Settings {
|
||||
lines.push(format!(" locale: {}", self.locale));
|
||||
lines.push(format!(" composer_density: {}", self.composer_density));
|
||||
lines.push(format!(" composer_border: {}", self.composer_border));
|
||||
lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode));
|
||||
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
lines.push(format!(
|
||||
|
||||
+309
-1
@@ -432,8 +432,38 @@ struct YoloRestoreState {
|
||||
|
||||
// === Sub-state structs for App field organization (#377) ===
|
||||
|
||||
/// Vim modal editing mode for the composer input area.
|
||||
///
|
||||
/// Enabled via `[composer] mode = "vim"` in `settings.toml`. When the
|
||||
/// composer vim mode is active the user starts in `Normal` mode and presses
|
||||
/// `i`, `a`, or `o` to enter `Insert` mode. `Esc` from `Insert` returns to
|
||||
/// `Normal`. Standard vim motions (`h`/`j`/`k`/`l`, `w`/`b`, `0`/`$`, `x`,
|
||||
/// `dd`) work in `Normal` mode. `Visual` is reserved for future selection
|
||||
/// support and currently behaves like `Normal`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VimMode {
|
||||
/// Normal / command mode — motions and operators, no text insertion.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Insert mode — characters are appended at the cursor as typed.
|
||||
Insert,
|
||||
/// Visual mode — reserved for future selection support.
|
||||
Visual,
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
/// Short status-bar label shown in the composer border.
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "-- NORMAL --",
|
||||
Self::Insert => "-- INSERT --",
|
||||
Self::Visual => "-- VISUAL --",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composer input state — grouped fields for the text input area.
|
||||
#[derive(Default)]
|
||||
pub struct ComposerState {
|
||||
/// Current composer text content.
|
||||
pub input: String,
|
||||
@@ -452,6 +482,39 @@ pub struct ComposerState {
|
||||
pub slash_menu_hidden: bool,
|
||||
pub mention_menu_selected: usize,
|
||||
pub mention_menu_hidden: bool,
|
||||
/// Whether vim modal editing is enabled for this composer.
|
||||
/// Sourced from `Settings::composer_vim_mode` at startup.
|
||||
pub vim_enabled: bool,
|
||||
/// Current vim editing mode. Only meaningful when `vim_enabled` is true.
|
||||
pub vim_mode: VimMode,
|
||||
/// Pending `d` prefix for the `dd` delete-line operator. Set when the
|
||||
/// user presses `d` in Normal mode; cleared on the next key (either `d`
|
||||
/// to complete `dd`, or any other key to cancel).
|
||||
pub vim_pending_d: bool,
|
||||
}
|
||||
|
||||
impl Default for ComposerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input: String::new(),
|
||||
cursor_position: 0,
|
||||
kill_buffer: String::new(),
|
||||
paste_burst: PasteBurst::default(),
|
||||
input_history: Vec::new(),
|
||||
draft_history: VecDeque::new(),
|
||||
history_index: None,
|
||||
history_navigation_draft: None,
|
||||
composer_history_search: None,
|
||||
selected_attachment_index: None,
|
||||
slash_menu_selected: 0,
|
||||
slash_menu_hidden: false,
|
||||
mention_menu_selected: 0,
|
||||
mention_menu_hidden: false,
|
||||
vim_enabled: false,
|
||||
vim_mode: VimMode::Normal,
|
||||
vim_pending_d: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewport/scroll state — fields related to transcript scrolling and caching.
|
||||
@@ -1004,6 +1067,8 @@ impl App {
|
||||
let ui_locale = resolve_locale(&settings.locale);
|
||||
let composer_density = ComposerDensity::from_setting(&settings.composer_density);
|
||||
let composer_border = settings.composer_border;
|
||||
let composer_vim_enabled =
|
||||
settings.composer_vim_mode.trim().to_ascii_lowercase() == "vim";
|
||||
let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
let sidebar_width_percent = settings.sidebar_width_percent;
|
||||
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
|
||||
@@ -1082,6 +1147,9 @@ impl App {
|
||||
slash_menu_hidden: false,
|
||||
mention_menu_selected: 0,
|
||||
mention_menu_hidden: false,
|
||||
vim_enabled: composer_vim_enabled,
|
||||
vim_mode: VimMode::Normal,
|
||||
vim_pending_d: false,
|
||||
},
|
||||
viewport: ViewportState::default(),
|
||||
goal: GoalState::default(),
|
||||
@@ -2708,6 +2776,246 @@ impl App {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
// === Vim composer mode helpers ===
|
||||
|
||||
/// Move the cursor to the start of the current logical line (vim `0`).
|
||||
pub fn vim_move_line_start(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
|
||||
// Walk backward until we find a newline or the start of the string.
|
||||
let line_start_byte = text[..cursor_byte]
|
||||
.rfind('\n')
|
||||
.map_or(0, |idx| idx + 1);
|
||||
self.cursor_position = char_count(&text[..line_start_byte]);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Move the cursor to the end of the current logical line (vim `$`).
|
||||
pub fn vim_move_line_end(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
|
||||
// Walk forward to the next newline or end-of-string.
|
||||
let line_end_char = text[cursor_byte..]
|
||||
.find('\n')
|
||||
.map_or_else(|| char_count(&text), |rel| {
|
||||
char_count(&text[..cursor_byte + rel])
|
||||
});
|
||||
self.cursor_position = line_end_char;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Move forward one word (vim `w`). Skips over the current word then any
|
||||
/// trailing whitespace to land on the first character of the next word.
|
||||
pub fn vim_move_word_forward(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let total = char_count(&text);
|
||||
let mut pos = self.cursor_position;
|
||||
if pos >= total {
|
||||
return;
|
||||
}
|
||||
// Skip non-whitespace (current word).
|
||||
while pos < total {
|
||||
let byte = byte_index_at_char(&text, pos);
|
||||
let ch = text[byte..].chars().next().unwrap_or(' ');
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
// Skip whitespace.
|
||||
while pos < total {
|
||||
let byte = byte_index_at_char(&text, pos);
|
||||
let ch = text[byte..].chars().next().unwrap_or(' ');
|
||||
if !ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
self.cursor_position = pos;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Move backward one word (vim `b`). Skips leading whitespace then the
|
||||
/// preceding word to land on its first character.
|
||||
pub fn vim_move_word_backward(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let mut pos = self.cursor_position;
|
||||
if pos == 0 {
|
||||
return;
|
||||
}
|
||||
// Step back one so we're not already at the word start.
|
||||
pos -= 1;
|
||||
// Skip whitespace.
|
||||
while pos > 0 {
|
||||
let byte = byte_index_at_char(&text, pos);
|
||||
let ch = text[byte..].chars().next().unwrap_or(' ');
|
||||
if !ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
pos -= 1;
|
||||
}
|
||||
// Skip non-whitespace.
|
||||
while pos > 0 {
|
||||
let byte = byte_index_at_char(&text, pos - 1);
|
||||
let ch = text[byte..].chars().next().unwrap_or(' ');
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
pos -= 1;
|
||||
}
|
||||
self.cursor_position = pos;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Delete the character under the cursor (vim `x`).
|
||||
pub fn vim_delete_char_under_cursor(&mut self) {
|
||||
let total = char_count(&self.input);
|
||||
if self.cursor_position >= total {
|
||||
return;
|
||||
}
|
||||
let pos = self.cursor_position;
|
||||
remove_char_at(&mut self.input, pos);
|
||||
// Keep cursor in bounds after deletion.
|
||||
let new_total = char_count(&self.input);
|
||||
if self.cursor_position > 0 && self.cursor_position >= new_total {
|
||||
self.cursor_position = new_total.saturating_sub(1);
|
||||
}
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Delete the entire current logical line (vim `dd`).
|
||||
pub fn vim_delete_line(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
|
||||
let line_start_byte = text[..cursor_byte]
|
||||
.rfind('\n')
|
||||
.map_or(0, |idx| idx + 1);
|
||||
let line_end_byte = text[cursor_byte..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |rel| cursor_byte + rel);
|
||||
|
||||
// Include the trailing newline if present, or the leading newline for the
|
||||
// very last non-terminated line to avoid leaving a dangling newline.
|
||||
let (remove_start, remove_end) = if line_end_byte < text.len() {
|
||||
// There is a newline after the line — remove it too.
|
||||
(line_start_byte, line_end_byte + 1)
|
||||
} else if line_start_byte > 0 {
|
||||
// Last line without trailing newline — remove the preceding newline.
|
||||
(line_start_byte - 1, line_end_byte)
|
||||
} else {
|
||||
// Only line in the buffer.
|
||||
(line_start_byte, line_end_byte)
|
||||
};
|
||||
|
||||
self.input.replace_range(remove_start..remove_end, "");
|
||||
self.cursor_position = char_count(&self.input[..remove_start]);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Enter insert mode at the cursor (vim `i`).
|
||||
pub fn vim_enter_insert(&mut self) {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Enter insert mode after the cursor (vim `a`).
|
||||
pub fn vim_enter_append(&mut self) {
|
||||
let total = char_count(&self.input);
|
||||
if self.cursor_position < total {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
self.vim_mode = VimMode::Insert;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Open a new line below and enter insert mode (vim `o`).
|
||||
pub fn vim_open_line_below(&mut self) {
|
||||
// Move to end of line, then insert a newline.
|
||||
self.vim_move_line_end();
|
||||
self.insert_char('\n');
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
|
||||
/// Return to Normal mode from Insert or Visual (vim `Esc`).
|
||||
pub fn vim_enter_normal(&mut self) {
|
||||
self.vim_mode = VimMode::Normal;
|
||||
self.vim_pending_d = false;
|
||||
// In Normal mode the cursor sits on a character, not after the last one.
|
||||
let total = char_count(&self.input);
|
||||
if self.cursor_position > 0 && self.cursor_position >= total {
|
||||
self.cursor_position = total.saturating_sub(1);
|
||||
}
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Returns `true` when vim mode is active and the composer is in Normal
|
||||
/// mode, which means character keys should NOT be inserted as text.
|
||||
#[must_use]
|
||||
pub fn vim_is_normal_mode(&self) -> bool {
|
||||
self.composer.vim_enabled && self.composer.vim_mode == VimMode::Normal
|
||||
}
|
||||
|
||||
/// Returns `true` when vim mode is active and the composer is in Visual mode.
|
||||
#[must_use]
|
||||
pub fn vim_is_visual_mode(&self) -> bool {
|
||||
self.composer.vim_enabled && self.composer.vim_mode == VimMode::Visual
|
||||
}
|
||||
|
||||
/// Move the cursor down one logical line within the buffer (vim `j`).
|
||||
/// Falls back to history-down when already on the last line.
|
||||
pub fn vim_move_down(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let total = char_count(&text);
|
||||
if self.cursor_position >= total {
|
||||
self.history_down();
|
||||
return;
|
||||
}
|
||||
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
|
||||
let rest = &text[cursor_byte..];
|
||||
if let Some(rel_nl) = rest.find('\n') {
|
||||
// Column offset on the current line.
|
||||
let line_start_byte = text[..cursor_byte]
|
||||
.rfind('\n')
|
||||
.map_or(0, |i| i + 1);
|
||||
let col = char_count(&text[line_start_byte..cursor_byte]);
|
||||
let next_line_start = cursor_byte + rel_nl + 1;
|
||||
let next_line = &text[next_line_start..];
|
||||
let next_line_len = next_line.find('\n').unwrap_or(next_line.len());
|
||||
let next_line_char_len = char_count(&text[next_line_start
|
||||
..next_line_start + next_line_len]);
|
||||
let target_col = col.min(next_line_char_len);
|
||||
self.cursor_position =
|
||||
char_count(&text[..next_line_start]) + target_col;
|
||||
self.needs_redraw = true;
|
||||
} else {
|
||||
self.history_down();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor up one logical line within the buffer (vim `k`).
|
||||
/// Falls back to history-up when already on the first line.
|
||||
pub fn vim_move_up(&mut self) {
|
||||
let text = self.input.clone();
|
||||
let cursor_byte = byte_index_at_char(&text, self.cursor_position);
|
||||
if let Some(prev_nl) = text[..cursor_byte].rfind('\n') {
|
||||
// Column on the current line.
|
||||
let line_start_byte = prev_nl + 1;
|
||||
let col = char_count(&text[line_start_byte..cursor_byte]);
|
||||
// Find start of the previous line.
|
||||
let prev_line_end = prev_nl; // byte of the newline itself
|
||||
let prev_start = text[..prev_line_end]
|
||||
.rfind('\n')
|
||||
.map_or(0, |i| i + 1);
|
||||
let prev_line_len = char_count(&text[prev_start..prev_line_end]);
|
||||
let target_col = col.min(prev_line_len);
|
||||
self.cursor_position =
|
||||
char_count(&text[..prev_start]) + target_col;
|
||||
self.needs_redraw = true;
|
||||
} else {
|
||||
self.history_up();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_input(&mut self) {
|
||||
self.clear_input_history_navigation();
|
||||
self.input.clear();
|
||||
|
||||
@@ -2095,6 +2095,16 @@ async fn run_event_loop(
|
||||
let _ = engine_handle.send(Op::Shutdown).await;
|
||||
return Ok(());
|
||||
}
|
||||
// Vim composer mode: Esc from Insert/Visual → Normal.
|
||||
// This arm runs before the generic Esc handler so Insert mode
|
||||
// Esc doesn't accidentally cancel an in-flight request.
|
||||
KeyCode::Esc
|
||||
if app.composer.vim_enabled
|
||||
&& app.composer.vim_mode != crate::tui::app::VimMode::Normal =>
|
||||
{
|
||||
app.vim_enter_normal();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Esc if app.clear_composer_attachment_selection() => {
|
||||
continue;
|
||||
}
|
||||
@@ -2670,6 +2680,28 @@ async fn run_event_loop(
|
||||
open_tool_details_pager(app);
|
||||
continue;
|
||||
}
|
||||
// Vim composer: Normal-mode motion / operator keys.
|
||||
// Only fires when vim is enabled, the input is focused (no modal
|
||||
// open on top), and the key has no modifier (pure char).
|
||||
KeyCode::Char(c)
|
||||
if app.vim_is_normal_mode()
|
||||
&& key.modifiers.is_empty()
|
||||
&& !slash_menu_open
|
||||
&& !mention_menu_open
|
||||
&& app.view_stack.is_empty() =>
|
||||
{
|
||||
handle_vim_normal_key(app, c);
|
||||
continue;
|
||||
}
|
||||
// Vim composer: in Visual mode plain chars are ignored
|
||||
// (no text insertion until `i` / `a` enters Insert).
|
||||
KeyCode::Char(_)
|
||||
if app.vim_is_visual_mode()
|
||||
&& key.modifiers.is_empty()
|
||||
&& app.view_stack.is_empty() =>
|
||||
{
|
||||
// absorb — Visual mode not yet fully implemented
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.insert_char(c);
|
||||
}
|
||||
@@ -2683,6 +2715,87 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a plain character key press when the composer is in vim Normal mode.
|
||||
///
|
||||
/// Implements the core set of normal-mode bindings:
|
||||
/// - `h` / `l` — left / right by character
|
||||
/// - `j` / `k` — down / up by logical line (falls back to prev/next history)
|
||||
/// - `w` / `b` — word forward / backward
|
||||
/// - `0` / `$` — line start / end
|
||||
/// - `x` — delete character under cursor
|
||||
/// - `d` (×2) — delete current line (`dd`)
|
||||
/// - `i` — enter Insert before cursor
|
||||
/// - `a` — enter Insert after cursor
|
||||
/// - `o` — open new line below and enter Insert
|
||||
/// - `v` — enter Visual mode
|
||||
/// - `G` — move to end of buffer
|
||||
fn handle_vim_normal_key(app: &mut App, c: char) {
|
||||
use crate::tui::app::VimMode;
|
||||
|
||||
// Handle pending `d` (waiting for second `d` to complete `dd`).
|
||||
if app.composer.vim_pending_d {
|
||||
app.composer.vim_pending_d = false;
|
||||
if c == 'd' {
|
||||
app.vim_delete_line();
|
||||
}
|
||||
// Any other key cancels the pending operator.
|
||||
return;
|
||||
}
|
||||
|
||||
match c {
|
||||
'h' => {
|
||||
app.move_cursor_left();
|
||||
}
|
||||
'l' => {
|
||||
app.move_cursor_right();
|
||||
}
|
||||
'j' => {
|
||||
app.vim_move_down();
|
||||
}
|
||||
'k' => {
|
||||
app.vim_move_up();
|
||||
}
|
||||
'w' => {
|
||||
app.vim_move_word_forward();
|
||||
}
|
||||
'b' => {
|
||||
app.vim_move_word_backward();
|
||||
}
|
||||
'0' => {
|
||||
app.vim_move_line_start();
|
||||
}
|
||||
'$' => {
|
||||
app.vim_move_line_end();
|
||||
}
|
||||
'x' => {
|
||||
app.vim_delete_char_under_cursor();
|
||||
}
|
||||
'd' => {
|
||||
// Start the `dd` operator sequence.
|
||||
app.composer.vim_pending_d = true;
|
||||
}
|
||||
'i' => {
|
||||
app.vim_enter_insert();
|
||||
}
|
||||
'a' => {
|
||||
app.vim_enter_append();
|
||||
}
|
||||
'o' => {
|
||||
app.vim_open_line_below();
|
||||
}
|
||||
'v' => {
|
||||
app.composer.vim_mode = VimMode::Visual;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
'G' => {
|
||||
app.move_cursor_end();
|
||||
}
|
||||
_ => {
|
||||
// Unknown normal-mode key — silently ignored in Normal mode.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
|
||||
app.set_sidebar_focus(SidebarFocus::Agents);
|
||||
app.status_message = Some("Sidebar focus: agents".to_string());
|
||||
|
||||
@@ -23,7 +23,7 @@ pub use renderable::Renderable;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, AppMode, ComposerDensity};
|
||||
use crate::tui::app::{App, AppMode, ComposerDensity, VimMode};
|
||||
use crate::tui::approval::{
|
||||
ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory,
|
||||
};
|
||||
@@ -566,6 +566,23 @@ impl Renderable for ComposerWidget<'_> {
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.style(background);
|
||||
// Vim mode indicator — shown in the top-right corner of the
|
||||
// composer border when vim editing is active.
|
||||
if self.app.composer.vim_enabled {
|
||||
let color = match self.app.composer.vim_mode {
|
||||
VimMode::Normal => palette::TEXT_MUTED,
|
||||
VimMode::Insert => palette::DEEPSEEK_SKY,
|
||||
VimMode::Visual => palette::MODE_PLAN,
|
||||
};
|
||||
let label = self.app.composer.vim_mode.label();
|
||||
block = block.title_top(
|
||||
Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(color).bold(),
|
||||
))
|
||||
.right_aligned(),
|
||||
);
|
||||
}
|
||||
if let Some(hint_line) = hint_line {
|
||||
block = block.title_bottom(hint_line);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user