Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Action:** Update `notice_line` in `src/shell/status_bar.rs` to include a visual indicator (e.g., prefix with an info/warning symbol like `i` or `!`, or Nerd Font icon if supported, but simpler is safer if we want it to work without Nerd Fonts. Actually, let's use standard unicode characters like `✓` for success/info and `⚠` for error).

## $(date +%Y-%m-%d) - Edit Mode Notice Consistency
## 2026-10-12 - Edit Mode Notice Consistency
**Learning:** Edit mode specific notices (`EditState.notice`) previously bypassed the standard `notice_line` styling, resulting in warnings (unsaved changes) and errors (save failed) lacking visual distinction (color and icon) from normal hints.
**Action:** Always ensure nested or state-specific notice mechanisms reuse the top-level standard styling to provide a consistent visual language across all modes.

Expand All @@ -19,3 +19,7 @@
## 2024-11-21 - Centering Empty States for TUI Lists
**Learning:** Rendering an empty list message (like "no matches") in the top-left corner of the list area can look unbalanced, especially when rendering lists in large UI components or side panes.
**Action:** Always center the text for empty state indicators in lists visually (using `.alignment(ratatui::layout::Alignment::Center)`) and vertically (using `center_text(msg, area.height)`) when using `Paragraph` to render empty states in a list widget.

## 2026-10-12 - Input Placeholder Accessibility
**Learning:** In TUI interfaces, search/filter input fields with empty queries can appear as completely blank spaces, providing poor visual guidance to the user.
**Action:** Always add descriptive placeholder text styled distinctively (e.g., using `Color::DarkGray`) when the query is empty. Ensure to account for the width of the placeholder text when calculating padding for alignment to maintain balanced layouts.
24 changes: 17 additions & 7 deletions src/component/branch/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,25 @@ pub(crate) fn draw_branch(frame: &mut Frame, state: &mut BranchState, area: Rect
fn draw_input(frame: &mut Frame, state: &BranchState, area: Rect) {
let query_text = format!("> {}", state.query);
let count = format!("{}/{}", state.matches.len(), state.total());
let used = query_text.chars().count() + 1 + count.chars().count();
let pad = (area.width as usize).saturating_sub(used);
let line = Line::from(vec![
let mut spans = vec![
Span::raw(query_text),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::raw(" ".repeat(pad)),
Span::styled(count, Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(line), area);
];

let mut used = state.query.chars().count() + 3 + count.chars().count();
if state.query.is_empty() {
let placeholder = "filter branches...";
spans.push(Span::styled(
placeholder,
Style::default().fg(Color::DarkGray),
));
used += placeholder.chars().count();
}

let pad = (area.width as usize).saturating_sub(used);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(count, Style::default().fg(Color::DarkGray)));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}

fn draw_list(frame: &mut Frame, state: &mut BranchState, area: Rect) {
Expand Down
136 changes: 136 additions & 0 deletions src/component/branch/view.rs.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, HighlightSpacing, List, ListItem, Paragraph};

use crate::component::branch::{BranchRow, BranchState};

use crate::widget::{center_text, centered_rect};

pub(crate) fn draw_branch(frame: &mut Frame, state: &mut BranchState, area: Rect) {
let popup = centered_rect(70, 60, area);
frame.render_widget(Clear, popup);

let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title("branch (b)");
let inner = block.inner(popup);
frame.render_widget(block, popup);

let [input_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner);
draw_input(frame, state, input_area);
draw_list(frame, state, list_area);
}

fn draw_input(frame: &mut Frame, state: &BranchState, area: Rect) {
let query_text = format!("> {}", state.query);
let count = format!("{}/{}", state.matches.len(), state.total());
let used = query_text.chars().count() + 1 + count.chars().count();
let pad = (area.width as usize).saturating_sub(used);
let line = Line::from(vec![
Span::raw(query_text),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::raw(" ".repeat(pad)),
Span::styled(count, Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(line), area);
}

fn draw_list(frame: &mut Frame, state: &mut BranchState, area: Rect) {
if state.matches.is_empty() {
let p = Paragraph::new(center_text("no matches", area.height))
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(p, area);
return;
}

let items: Vec<ListItem> = state
.matches
.iter()
.map(|m| {
let spans = state
.row(m.row)
.map(|row| branch_line(row, &m.positions))
.unwrap_or_default();
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎")
.highlight_spacing(HighlightSpacing::Always);
let selected = (!state.matches.is_empty()).then_some(state.selected);
state.list_state.select(selected);
frame.render_stateful_widget(list, area, &mut state.list_state);
}

// 1行 = マーカー(現在ブランチ) + local/remote タグ + ブランチ名 (マッチ char をハイライト) +
// upstream + 相対日時 + 件名。名前列だけ char 単位で分割するのは finder_panel の
// highlight_finder_match と同じ理由 (positions が char インデックスのため)
const NAME_COL_WIDTH: usize = 24;

fn branch_line(row: &BranchRow, positions: &[usize]) -> Vec<Span<'static>> {
let marker = if row.current { "* " } else { " " };
let tag = if row.entry.remote { "remote" } else { "local " };
let tag_style = if row.entry.remote {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Green)
};
let upstream = row.entry.upstream.as_deref().unwrap_or("-");

let mut spans = vec![
Span::raw(marker.to_string()),
Span::styled(format!("{tag} "), tag_style),
];
spans.extend(highlight_name(row, positions));
let name_len = row.entry.name.chars().count();
spans.push(Span::raw(
" ".repeat(NAME_COL_WIDTH.saturating_sub(name_len)),
));
spans.push(Span::styled(
format!(" {upstream:<20}"),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled(
format!(" {:<12}", row.entry.relative_time),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::raw(format!(" {}", row.entry.subject)));
spans
}

fn highlight_name(row: &BranchRow, positions: &[usize]) -> Vec<Span<'static>> {
let base_style = if row.current {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if row.entry.remote {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let match_style = base_style
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED);
let mut spans = Vec::new();
let mut pos_iter = positions.iter().peekable();
for (i, ch) in row.entry.name.chars().enumerate() {
let style = if pos_iter.peek() == Some(&&i) {
pos_iter.next();
match_style
} else {
base_style
};
spans.push(Span::styled(ch.to_string(), style));
}
spans
}
23 changes: 15 additions & 8 deletions src/component/finder/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,23 @@ fn draw_finder_input(frame: &mut Frame, finder: &Finder, area: Rect) {
let count = format!("{}/{}", finder.matches.len(), finder.total());
// 入力とカーソルの右側を件数表示までスペースで埋める。入力欄がそれより
// 狭ければ埋めず単純に連結するだけにする (折り返しは Paragraph に任せる)
let used = query_text.chars().count() + 1 + count.chars().count();
let pad = (area.width as usize).saturating_sub(used);
let line = Line::from(vec![
let mut spans = vec![
Span::raw(query_text),
// 常に末尾に立つ簡易カーソル (他の入力行と同じ表現)
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::raw(" ".repeat(pad)),
Span::styled(count, Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(line), area);
];
let mut used = finder.query.chars().count() + 3 + count.chars().count();
if finder.query.is_empty() {
let placeholder = "search files...";
spans.push(Span::styled(
placeholder,
Style::default().fg(Color::DarkGray),
));
used += placeholder.chars().count();
}
let pad = (area.width as usize).saturating_sub(used);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(count, Style::default().fg(Color::DarkGray)));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}

fn draw_finder_list(frame: &mut Frame, finder: &mut Finder, area: Rect) {
Expand Down
99 changes: 99 additions & 0 deletions src/component/finder/view.rs.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, HighlightSpacing, List, ListItem, Paragraph};

use crate::component::finder::Finder;

use crate::widget::{center_text, centered_rect};

// scanning は FileIndex の背景走査中フラグ (候補が読み込み済み分だけの暫定値であることを示す)。
// Finder 自身は走査の進行を知らないので、呼び出し側から渡してもらう
pub(crate) fn draw_finder(frame: &mut Frame, finder: &mut Finder, scanning: bool, area: Rect) {
let title = if scanning {
"finder (Ctrl+p) scanning..."
} else {
"finder (Ctrl+p)"
};
let popup = centered_rect(60, 60, area);
// 下のツリー/ビューアを隠すため、描画前に領域をクリアする
frame.render_widget(Clear, popup);

let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(title);
let inner = block.inner(popup);
frame.render_widget(block, popup);

let [input_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner);
draw_finder_input(frame, finder, input_area);
draw_finder_list(frame, finder, list_area);
}

fn draw_finder_input(frame: &mut Frame, finder: &Finder, area: Rect) {
let query_text = format!("> {}", finder.query);
let count = format!("{}/{}", finder.matches.len(), finder.total());
// 入力とカーソルの右側を件数表示までスペースで埋める。入力欄がそれより
// 狭ければ埋めず単純に連結するだけにする (折り返しは Paragraph に任せる)
let used = query_text.chars().count() + 1 + count.chars().count();
let pad = (area.width as usize).saturating_sub(used);
let line = Line::from(vec![
Span::raw(query_text),
// 常に末尾に立つ簡易カーソル (他の入力行と同じ表現)
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::raw(" ".repeat(pad)),
Span::styled(count, Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(line), area);
}

fn draw_finder_list(frame: &mut Frame, finder: &mut Finder, area: Rect) {
if finder.matches.is_empty() {
let p = Paragraph::new(center_text("no matches", area.height))
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(p, area);
return;
}

let items: Vec<ListItem> = finder
.matches
.iter()
.map(|m| {
let path = finder.candidate_path(m.candidate).unwrap_or_default();
ListItem::new(Line::from(highlight_finder_match(path, &m.positions)))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎")
.highlight_spacing(HighlightSpacing::Always);
let selected = (!finder.matches.is_empty()).then_some(finder.selected);
finder.list_state.select(selected);
frame.render_stateful_widget(list, area, &mut finder.list_state);
}

// マッチした char (positions は昇順) を強調色で塗った span 列を組み立てる
fn highlight_finder_match(path: &str, positions: &[usize]) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut pos_iter = positions.iter().peekable();
for (i, ch) in path.chars().enumerate() {
let style = if pos_iter.peek() == Some(&&i) {
pos_iter.next();
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
spans.push(Span::styled(ch.to_string(), style));
}
spans
}
1 change: 1 addition & 0 deletions src/component/grep/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ impl Cache {

/// 予約を戻す。古い世代の走査が差し替え後に戻すと (差し替えで正確な値に置き直した後なので)
/// 引きすぎになるため、0 で止める。ずれは次の差し替えで正確な値に戻る
#[allow(deprecated)]
fn release(&self, len: usize) {
let _ = self
.bytes
Expand Down
17 changes: 12 additions & 5 deletions src/component/grep/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,19 @@ fn title(grep: &GrepState) -> String {
}

fn draw_input(frame: &mut Frame, grep: &GrepState, area: Rect) {
let line = Line::from(vec![
Span::raw(format!("> {}", grep.query)),
// 常に末尾に立つ簡易カーソル (Finder と同じ表現)
let query_text = format!("> {}", grep.query);
let mut spans = vec![
Span::raw(query_text),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
]);
frame.render_widget(Paragraph::new(line), area);
];
if grep.query.is_empty() {
let placeholder = "search text...";
spans.push(Span::styled(
placeholder,
Style::default().fg(Color::DarkGray),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}

fn draw_list(frame: &mut Frame, grep: &mut GrepState, area: Rect) {
Expand Down
Loading
Loading