Merge pull request #595 from peco/gh-445

Add new layout, refactr layout lookup/registration
This commit is contained in:
lestrrat 2026-02-16 15:10:22 +09:00 committed by GitHub
commit 757abc8b66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 122 additions and 14 deletions

View file

@ -437,8 +437,7 @@ func doToggleSelectionAndSelectNext(ctx context.Context, state *Peco, e Event) {
state.Hub().Batch(ctx, func(ctx context.Context) {
ctx = context.WithValue(ctx, isTopLevelActionCall, false)
doToggleSelection(ctx, state, e)
// XXX This is sucky. Fix later
if state.LayoutType() == "top-down" {
if state.LayoutType() != LayoutTypeBottomUp {
doSelectDown(ctx, state, e)
} else {
doSelectUp(ctx, state, e)

View file

@ -33,9 +33,10 @@ const (
)
const (
DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom
LayoutTypeTopDown = "top-down" // LayoutTypeBottomUp changes the layout to read from bottom to up
LayoutTypeBottomUp = "bottom-up"
DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom
LayoutTypeTopDown = "top-down" // LayoutTypeTopDown displays prompt at top, list top-to-bottom
LayoutTypeBottomUp = "bottom-up" // LayoutTypeBottomUp displays prompt at bottom, list bottom-to-top
LayoutTypeTopDownQueryBottom = "top-down-query-bottom" // LayoutTypeTopDownQueryBottom displays list top-to-bottom, prompt at bottom
)
const (
@ -195,6 +196,7 @@ type Layout interface {
DrawScreen(*Peco, *DrawOptions)
MovePage(*Peco, PagingRequest) (moved bool)
PurgeDisplayCache()
SortTopDown() bool
}
// AnchorSettings groups items that are required to control
@ -457,7 +459,7 @@ type CLIOptions struct {
OptInitialMatcher string `long:"initial-matcher" description:"specify the default matcher (deprecated)"`
OptInitialFilter string `long:"initial-filter" description:"specify the default filter"`
OptPrompt string `long:"prompt" description:"specify the prompt string"`
OptLayout string `long:"layout" description:"layout to be used. 'top-down' or 'bottom-up'. default is 'top-down'"`
OptLayout string `long:"layout" description:"layout to be used. 'top-down', 'bottom-up', or 'top-down-query-bottom'. default is 'top-down'"`
OptSelect1 bool `long:"select-1" description:"select first item and immediately exit if the input contains only 1 item"`
OptExitZero bool `long:"exit-0" description:"exit immediately with status 1 if the input is empty"`
OptSelectAll bool `long:"select-all" description:"select all items and immediately exit"`

View file

@ -15,9 +15,28 @@ import (
var extraOffset int = 0
// LayoutFactory is a function that creates a BasicLayout for the given Peco state.
type LayoutFactory func(*Peco) *BasicLayout
var layoutRegistry = map[LayoutType]LayoutFactory{}
// RegisterLayout registers a layout factory under the given name.
func RegisterLayout(name LayoutType, factory LayoutFactory) {
layoutRegistry[name] = factory
}
// NewLayout creates a layout by looking up the registry. Falls back to top-down.
func NewLayout(layoutType LayoutType, state *Peco) *BasicLayout {
if factory, ok := layoutRegistry[layoutType]; ok {
return factory(state)
}
return layoutRegistry[LayoutTypeTopDown](state)
}
// IsValidLayoutType checks if a string is a supported layout type
func IsValidLayoutType(v LayoutType) bool {
return v == LayoutTypeTopDown || v == LayoutTypeBottomUp
_, ok := layoutRegistry[v]
return ok
}
// IsValidVerticalAnchor checks if the specified anchor is supported
@ -656,6 +675,31 @@ func NewBottomUpLayout(state *Peco) *BasicLayout {
}
}
// NewTopDownQueryBottomLayout creates a new Layout with list top-to-bottom
// and the query prompt at the bottom.
func NewTopDownQueryBottomLayout(state *Peco) *BasicLayout {
return &BasicLayout{
statusBar: newStatusBar(state),
screen: state.Screen(),
// The prompt is at the bottom, above the status bar
prompt: NewUserPrompt(state.Screen(), AnchorBottom, 1+extraOffset, state.Prompt(), state.Styles()),
// The list area is at the top
// It's displayed in top-to-bottom order
list: NewListArea(state.Screen(), AnchorTop, 0, true, state.Styles()),
}
}
// SortTopDown returns whether this layout sorts lines from top to bottom.
func (l *BasicLayout) SortTopDown() bool {
return l.list.sortTopDown
}
func init() {
RegisterLayout(LayoutTypeTopDown, NewDefaultLayout)
RegisterLayout(LayoutTypeBottomUp, NewBottomUpLayout)
RegisterLayout(LayoutTypeTopDownQueryBottom, NewTopDownQueryBottomLayout)
}
func (l *BasicLayout) PurgeDisplayCache() {
l.list.purgeDisplayCache()
}

View file

@ -17,6 +17,7 @@ func TestLayoutType(t *testing.T) {
}{
{LayoutTypeTopDown, true},
{LayoutTypeBottomUp, true},
{LayoutTypeTopDownQueryBottom, true},
{"foobar", false},
}
for _, l := range layouts {
@ -366,3 +367,71 @@ func TestGHIssue455_DrawScreenForceSync(t *testing.T) {
require.Empty(t, syncEvents, "expected no Sync calls with nil options")
})
}
// TestNewLayout verifies the layout registry returns correct layout types.
func TestNewLayout(t *testing.T) {
makeState := func() *Peco {
state := New()
state.screen = NewDummyScreen()
state.skipReadConfig = true
state.Filters().Add(filter.NewIgnoreCase())
return state
}
t.Run("top-down", func(t *testing.T) {
state := makeState()
layout := NewLayout(LayoutTypeTopDown, state)
require.NotNil(t, layout)
require.True(t, layout.SortTopDown(), "top-down layout should sort top-down")
require.Equal(t, AnchorTop, layout.prompt.anchor, "top-down prompt should be anchored at top")
})
t.Run("bottom-up", func(t *testing.T) {
state := makeState()
layout := NewLayout(LayoutTypeBottomUp, state)
require.NotNil(t, layout)
require.False(t, layout.SortTopDown(), "bottom-up layout should not sort top-down")
require.Equal(t, AnchorBottom, layout.prompt.anchor, "bottom-up prompt should be anchored at bottom")
})
t.Run("top-down-query-bottom", func(t *testing.T) {
state := makeState()
layout := NewLayout(LayoutTypeTopDownQueryBottom, state)
require.NotNil(t, layout)
require.True(t, layout.SortTopDown(), "top-down-query-bottom layout should sort top-down")
require.Equal(t, AnchorBottom, layout.prompt.anchor, "top-down-query-bottom prompt should be anchored at bottom")
require.Equal(t, AnchorTop, layout.list.anchor, "top-down-query-bottom list should be anchored at top")
})
t.Run("unknown falls back to top-down", func(t *testing.T) {
state := makeState()
layout := NewLayout("unknown-layout", state)
require.NotNil(t, layout)
require.True(t, layout.SortTopDown(), "fallback layout should sort top-down")
require.Equal(t, AnchorTop, layout.prompt.anchor, "fallback prompt should be anchored at top")
})
}
// TestTopDownQueryBottomLayout verifies the specific properties of the
// top-down-query-bottom layout.
func TestTopDownQueryBottomLayout(t *testing.T) {
state := New()
state.screen = NewDummyScreen()
state.skipReadConfig = true
state.Filters().Add(filter.NewIgnoreCase())
layout := NewTopDownQueryBottomLayout(state)
require.Equal(t, AnchorBottom, layout.prompt.anchor,
"prompt should be anchored at bottom")
require.Equal(t, 1+extraOffset, layout.prompt.anchorOffset,
"prompt anchor offset should be 1+extraOffset")
require.Equal(t, AnchorTop, layout.list.anchor,
"list should be anchored at top")
require.Equal(t, 0, layout.list.anchorOffset,
"list anchor offset should be 0")
require.True(t, layout.list.sortTopDown,
"list should sort top-down")
require.True(t, layout.SortTopDown(),
"SortTopDown() should return true")
}

View file

@ -25,13 +25,7 @@ func (jlr JumpToLineRequest) Line() int {
}
func NewView(state *Peco) *View {
var layout Layout
switch state.LayoutType() {
case LayoutTypeBottomUp:
layout = NewBottomUpLayout(state)
default:
layout = NewDefaultLayout(state)
}
layout := NewLayout(LayoutType(state.LayoutType()), state)
return &View{
state: state,
layout: layout,