diff --git a/.claude/plans/plan.md b/.claude/plans/plan.md index 8808ca3..f52b895 100644 --- a/.claude/plans/plan.md +++ b/.claude/plans/plan.md @@ -1,20 +1,15 @@ -# Plan: プレビュー機能の追加 +# Plan: キーバインディングヘルパーの追加 (issue #9) ## 要件 -- フォーカス中ペインの下に、カーソル行ファイルのプレビューを常時表示する。 - - Source フォーカス時は Source ペインの下、Dest フォーカス時は Dest ペインの下。 -- テキストファイルは内容、ディレクトリは中の一覧を表示。バイナリ/読み込み失敗は不可メッセージ。 +- `?` でキーバインド一覧(ヘルプ)を中央モーダルで表示する。 +- ヘルプ表示中は任意キー/Esc/q で閉じる。 +- 既存の `?`(後方検索)は廃止する。/ の前方検索と n/N の双方向巡回は維持。 ## タスク -- [x] 1. ブランチ作成(feat/preview-pane) -- [x] 2. FileSystem に ReadFile を追加(osfs 実装 + テスト用モック更新) -- [x] 3. 失敗するテストを書く(previewLines / isBinary / render 統合) -- [x] 4. previewLines・isBinary・renderPreview を実装しテストをパス -- [x] 5. render() にフォーカス側の縦分割を統合 +- [x] 1. ブランチ作成(feat/keybinding-help) +- [x] 2. 失敗するテストを書く(? でヘルプ表示 / 任意キーで閉じる / render にヘルプ内容) +- [x] 3. 後方検索の削除(InputModeSearchBackward, searchBackward, inputPrefix/isTextInputMode) +- [x] 4. ヘルプ機能の実装(help.go: helpEntries, renderHelpModal, showHelp 状態, ルーティング) +- [x] 5. ステータスバーに [?]Help を追記 + README 更新 - [x] 6. gofmt → golangci-lint → go test ./... → nix build -- [x] 7. PR 作成(#16) - -## 追加対応(レビュー反映) -- [x] 8. プレビュー高さを 50% に変更 -- [x] 9. プレビューをフォーカス可能にしスクロール対応(Tab巡回 + j/k + gg/G) -- [x] 10. gofmt → golangci-lint → go test ./... → nix build +- [ ] 7. PR 作成 diff --git a/README.md b/README.md index 250ef25..d84003f 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Keys available in the popup: | Key | Action | |-----|--------| | `.` | Toggle hidden files visibility | +| `?` | Show keybindings help (press any key / `Esc` to close) | | `q` | Quit | ## Basic Usage diff --git a/README_ja.md b/README_ja.md index 7857e64..fa20bda 100644 --- a/README_ja.md +++ b/README_ja.md @@ -136,6 +136,7 @@ Nerd Fonts をインストールしたターミナルで使用すると、ファ | キー | 操作 | |------|------| | `.` | 隠しファイルの表示/非表示を切り替え | +| `?` | キーバインド一覧(ヘルプ)を表示(任意キー / `Esc` で閉じる) | | `q` | 終了 | ## 基本的な使い方 diff --git a/internal/ui/help.go b/internal/ui/help.go new file mode 100644 index 0000000..ac6c66a --- /dev/null +++ b/internal/ui/help.go @@ -0,0 +1,70 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +// helpEntry は1つのキーバインドの表記と説明を表す。 +type helpEntry struct { + keys string // キー表記 + desc string // 説明 +} + +// helpEntries はヘルプモーダルに表示するキーバインド一覧を返す。 +func helpEntries() []helpEntry { + return []helpEntry{ + {"j / k", "Move cursor down / up"}, + {"h / l", "Go to parent / enter directory"}, + {"gg / G", "Jump to top / bottom"}, + {"Tab", "Switch left / right pane"}, + {"Shift+Tab", "Switch pane / preview"}, + {"Space", "Toggle selection"}, + {"Ctrl+a", "Select all"}, + {"Ctrl+r", "Invert selection"}, + {"y", "Yank (mark copy source)"}, + {"p / P", "Paste / overwrite paste"}, + {"d / D", "Move to trash / delete permanently"}, + {"a", "Create new (trailing / for directory)"}, + {"r", "Rename"}, + {"/", "Search"}, + {"n / N", "Next / previous search result"}, + {"f", "Filter"}, + {".", "Toggle hidden files"}, + {"s", "Select repository"}, + {"?", "Show this help"}, + {"Esc", "Clear selection / filter"}, + {"q", "Quit"}, + } +} + +// renderHelpModal はキーバインド一覧の中央モーダルを描画する。 +func (a *App) renderHelpModal() string { + entries := helpEntries() + + // キー表記カラムの最大幅を求めて説明をそろえる。 + keyWidth := 0 + for _, e := range entries { + if w := runewidth.StringWidth(e.keys); w > keyWidth { + keyWidth = w + } + } + + title := lipgloss.NewStyle().Bold(true).Render(" Keybindings ") + lines := make([]string, 0, len(entries)+1) + lines = append(lines, title, "") + for _, e := range entries { + pad := strings.Repeat(" ", keyWidth-runewidth.StringWidth(e.keys)) + lines = append(lines, " "+e.keys+pad+" "+e.desc) + } + lines = append(lines, "", " any key / Esc to close ") + + content := strings.Join(lines, "\n") + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorderActive). + Padding(0, 1) + return boxStyle.Render(content) +} diff --git a/internal/ui/keybinding.go b/internal/ui/keybinding.go index 898678a..415efa7 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -441,11 +441,11 @@ func (a *App) searchForward() tea.Cmd { return a.enterTextInput(InputModeSearch, "") } -// searchBackward は後方検索モードに入る。 -func (a *App) searchBackward() tea.Cmd { +// toggleHelp はキーバインドヘルプの表示/非表示を切り替える。 +func (a *App) toggleHelp() { a.resetGPending() - a.searchFwd = false - return a.enterTextInput(InputModeSearchBackward, "") + a.showHelp = !a.showHelp + a.Status = "" } // searchNext は次の検索結果に移動する。 @@ -509,10 +509,10 @@ func (a *App) inputSubmit() { a.input.SetValue("") switch mode { - case InputModeSearch, InputModeSearchBackward: + case InputModeSearch: a.searchQuery = input - // 確定時に検索方向を保存し、後続のn/Nが正しい方向を使えるようにする - a.searchFwd = mode == InputModeSearch + // 確定時に検索方向を前方として保存し、後続のn/Nが正しい方向を使えるようにする + a.searchFwd = true if input == "" { a.Status = "" return @@ -521,7 +521,7 @@ func (a *App) inputSubmit() { if p == nil { return } - a.doSearch(p, mode == InputModeSearch) + a.doSearch(p, true) case InputModeFilter: p := a.focusedPane() if p == nil { diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 56fbd9a..de1c478 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -26,8 +26,6 @@ const ( InputModeNone InputMode = iota // InputModeSearch は検索モード(/)。 InputModeSearch - // InputModeSearchBackward は後方検索モード(?)。 - InputModeSearchBackward // InputModeFilter はフィルタモード(f)。 InputModeFilter // InputModeCreate は新規作成モード(a)。 @@ -64,6 +62,8 @@ type App struct { input textinput.Model // 入力欄(検索/フィルタ/作成/リネーム) pendingTargets []string // 削除確認時のターゲットパスのスナップショット + showHelp bool // キーバインドヘルプを表示中か + previewFocused bool // プレビュー領域にフォーカスしているか previewScroll int // プレビューのスクロールオフセット(行) previewKey string // 現在プレビュー中の対象を識別するキー(変化でスクロールリセット) @@ -120,8 +120,6 @@ func (a *App) inputPrefix() string { switch a.inputMode { case InputModeSearch: return "/:" - case InputModeSearchBackward: - return "?:" case InputModeFilter: return "filter: " case InputModeCreate: @@ -135,7 +133,7 @@ func (a *App) inputPrefix() string { // isTextInputMode はテキスト入力を受け付けるモードかを返す。 func (a *App) isTextInputMode() bool { switch a.inputMode { - case InputModeSearch, InputModeSearchBackward, InputModeFilter, InputModeCreate, InputModeRename: + case InputModeSearch, InputModeFilter, InputModeCreate, InputModeRename: return true } return false diff --git a/internal/ui/model.go b/internal/ui/model.go index 5eb60a8..84de889 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -43,6 +43,8 @@ func (a *App) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { // handleKey はキー入力を現在のモードに応じてルーティングする。 func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { + case a.showHelp: + return a.handleHelpKey(msg) case a.inputMode == InputModeSelectRepo: return a.handleSelectorKey(msg) case a.isTextInputMode(): @@ -104,7 +106,7 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "/": return a, a.searchForward() case "?": - return a, a.searchBackward() + a.toggleHelp() case "n": a.searchNext() case "N": @@ -145,6 +147,14 @@ func (a *App) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } +// handleHelpKey はヘルプ表示中のキー入力を処理する。任意キーでヘルプを閉じる。 +func (a *App) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + a.resetGPending() + a.showHelp = false + a.Status = "" + return a, nil +} + // View はtea.Modelインターフェースの実装。画面全体を文字列として返す。 func (a *App) View() string { return a.render() diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index e0354cb..be2a3f5 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -212,30 +212,50 @@ func TestUpdate_削除確認_nでキャンセル(t *testing.T) { } } -func TestUpdate_後方検索_クエスチョンで方向を保存(t *testing.T) { +func TestUpdate_ヘルプ_クエスチョンで表示(t *testing.T) { app := newDestApp() - app.DestPane.Cursor = 1 // banana - // '?' で後方検索モードに入る + // '?' でヘルプを表示する app.Update(keyRunes("?")) - if app.inputMode != InputModeSearchBackward { - t.Fatalf("inputMode = %d, want InputModeSearchBackward", app.inputMode) + if !app.showHelp { + t.Fatal("? 押下後はshowHelpがtrueであるべき") } +} - // "apple" を入力して確定 → 後方検索でapple(idx0)にジャンプ - for _, ch := range []string{"a", "p", "p", "l", "e"} { - app.Update(keyRunes(ch)) +func TestUpdate_ヘルプ_任意キーで閉じる(t *testing.T) { + app := newDestApp() + app.showHelp = true + + // ヘルプ表示中に任意キーで閉じる + app.Update(keyRunes("j")) + if app.showHelp { + t.Error("任意キー押下後はshowHelpがfalseであるべき") } - app.Update(tea.KeyMsg{Type: tea.KeyEnter}) +} + +func TestUpdate_ヘルプ表示中はカーソルが動かない(t *testing.T) { + app := newDestApp() + app.DestPane.Cursor = 0 + app.showHelp = true - if app.searchFwd { - t.Error("後方検索確定後はsearchFwdがfalseであるべき") + // ヘルプを閉じるだけでカーソルは移動しない + app.Update(keyRunes("j")) + if app.DestPane.Cursor != 0 { + t.Errorf("Cursor = %d, want 0 (ヘルプを閉じるだけで移動しない)", app.DestPane.Cursor) } - if app.searchQuery != "apple" { - t.Errorf("searchQuery = %q, want \"apple\"", app.searchQuery) +} + +func TestRender_ヘルプ表示時はキー一覧を含む(t *testing.T) { + app := newDestApp() + app.width, app.height = 80, 24 + app.showHelp = true + + out := app.render() + if !strings.Contains(out, "Keybindings") { + t.Errorf("ヘルプにタイトル Keybindings が含まれない:\n%s", out) } - if app.DestPane.Cursor != 0 { - t.Errorf("Cursor = %d, want 0 (apple)", app.DestPane.Cursor) + if !strings.Contains(out, "Quit") { + t.Errorf("ヘルプに q の説明が含まれない:\n%s", out) } } diff --git a/internal/ui/render.go b/internal/ui/render.go index 6201900..eb81df3 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -68,6 +68,11 @@ func (a *App) render() string { return "" } + // ヘルプ表示中は中央モーダルで全画面を占有する + if a.showHelp { + return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, a.renderHelpModal()) + } + // リポジトリ選択ポップアップモードは中央モーダルで全画面を占有する if a.inputMode == InputModeSelectRepo { return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, a.renderSelectorModal()) @@ -262,7 +267,7 @@ func (a *App) renderStatus() string { if a.previewFocused { return fmt.Sprintf(" [%s preview] [q]Quit [Tab]Pane [S-Tab]Pane/Preview [j/k]Scroll", focus) } - return fmt.Sprintf(" [%s] [q]Quit [Tab]Pane [S-Tab]Preview [h/j/k/l]Move", focus) + return fmt.Sprintf(" [%s] [q]Quit [Tab]Pane [S-Tab]Preview [h/j/k/l]Move [?]Help", focus) } // renderSelectorModal はリポジトリ選択ポップアップのモーダルを描画する。