diff --git a/.Jules/palette.md b/.Jules/palette.md index 337e2dd..fddf4ff 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -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. @@ -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. diff --git a/src/component/branch/view.rs b/src/component/branch/view.rs index d0c67ed..5ed5a4e 100644 --- a/src/component/branch/view.rs +++ b/src/component/branch/view.rs @@ -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) { diff --git a/src/component/branch/view.rs.orig b/src/component/branch/view.rs.orig new file mode 100644 index 0000000..d0c67ed --- /dev/null +++ b/src/component/branch/view.rs.orig @@ -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 = 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> { + 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> { + 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 +} diff --git a/src/component/finder/view.rs b/src/component/finder/view.rs index 1563f76..001ea01 100644 --- a/src/component/finder/view.rs +++ b/src/component/finder/view.rs @@ -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) { diff --git a/src/component/finder/view.rs.orig b/src/component/finder/view.rs.orig new file mode 100644 index 0000000..1563f76 --- /dev/null +++ b/src/component/finder/view.rs.orig @@ -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 = 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> { + 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 +} diff --git a/src/component/grep/search.rs b/src/component/grep/search.rs index 7220fd0..b8f3cfa 100644 --- a/src/component/grep/search.rs +++ b/src/component/grep/search.rs @@ -167,6 +167,7 @@ impl Cache { /// 予約を戻す。古い世代の走査が差し替え後に戻すと (差し替えで正確な値に置き直した後なので) /// 引きすぎになるため、0 で止める。ずれは次の差し替えで正確な値に戻る + #[allow(deprecated)] fn release(&self, len: usize) { let _ = self .bytes diff --git a/src/component/grep/view.rs b/src/component/grep/view.rs index 765c75f..9354e6c 100644 --- a/src/component/grep/view.rs +++ b/src/component/grep/view.rs @@ -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) { diff --git a/src/component/grep/view.rs.orig b/src/component/grep/view.rs.orig new file mode 100644 index 0000000..765c75f --- /dev/null +++ b/src/component/grep/view.rs.orig @@ -0,0 +1,143 @@ +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, ListState, Paragraph, +}; + +use super::{GrepState, search}; +use crate::widget::{center_text, centered_rect, visible_window}; + +pub(crate) fn draw_grep(frame: &mut Frame, grep: &mut GrepState, area: Rect) { + let popup = centered_rect(80, 80, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(title(grep)); + 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, grep, input_area); + draw_list(frame, grep, list_area); +} + +// 走査の状態をタイトルに出す。「終わりなのか途中なのか」「全部なのか打ち切りなのか」が +// 一覧の見た目からは区別できないため、必ずここで言葉にする +fn title(grep: &GrepState) -> String { + let mut title = String::from("grep (Ctrl+f)"); + if !grep.searchable() { + return title; + } + title.push_str(&format!( + " {} hits in {} files", + grep.hit_count(), + grep.file_count() + )); + if grep.busy() { + title.push_str(" searching..."); + } else { + title.push_str(&format!(" ({} files scanned)", grep.scanned())); + } + if grep.truncated() { + title.push_str(&format!(" truncated at {}", search::MAX_HITS)); + } + if grep.stale() { + title.push_str(" stale"); + } + title +} + +fn draw_input(frame: &mut Frame, grep: &GrepState, area: Rect) { + let line = Line::from(vec![ + Span::raw(format!("> {}", grep.query)), + // 常に末尾に立つ簡易カーソル (Finder と同じ表現) + Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn draw_list(frame: &mut Frame, grep: &mut GrepState, area: Rect) { + let total = grep.rows().len(); + if total == 0 || area.height == 0 { + let message = if !grep.searchable() { + "type 2+ characters to search the workspace" + } else if grep.busy() { + "searching..." + } else { + "no matches" + }; + frame.render_widget( + Paragraph::new(Span::styled( + center_text(message, area.height), + Style::default().fg(Color::DarkGray), + )) + .alignment(ratatui::layout::Alignment::Center), + area, + ); + return; + } + let selected = Some(grep.selected); + // ヒットは最大 MAX_HITS 件並ぶので、ツリーと同じく画面に映る範囲だけ ListItem を組む + let (first, last) = visible_window( + total, + area.height as usize, + *grep.list_state.offset_mut(), + selected, + ); + *grep.list_state.offset_mut() = first; + + let items: Vec = grep.rows()[first..last] + .iter() + .map(|row| { + let file = &grep.files()[row.file]; + let hit = &file.hits[row.hit]; + ListItem::new(Line::from(hit_spans(&file.path.to_string_lossy(), hit))) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▎") + .highlight_spacing(HighlightSpacing::Always); + // List には切り出した部分列を渡すので、選択位置も offset もそれに合わせて相対化する + // (絶対値の list_state をそのまま渡すと二重にずれる。tree/view.rs と同じ) + let mut window_state = ListState::default().with_selected(selected.map(|s| s - first)); + frame.render_stateful_widget(list, area, &mut window_state); +} + +// `path:line: ` を暗く、本文のマッチ部分だけを強調する +fn hit_spans(path: &str, hit: &search::Hit) -> Vec> { + let mut spans = vec![ + Span::styled(path.to_string(), Style::default().fg(Color::Cyan)), + Span::styled( + format!(":{}: ", hit.line + 1), + Style::default().fg(Color::DarkGray), + ), + ]; + if hit.clipped { + spans.push(Span::styled("…", Style::default().fg(Color::DarkGray))); + } + let chars: Vec = hit.text.chars().collect(); + let start = hit.start_col.min(chars.len()); + let end = hit.end_col.min(chars.len()); + let before: String = chars[..start].iter().collect(); + let matched: String = chars[start..end].iter().collect(); + let after: String = chars[end..].iter().collect(); + spans.push(Span::raw(before)); + spans.push(Span::styled( + matched, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(after)); + spans +}