Compare commits

...
16 Commits
Author SHA1 Message Date
Stefan Haller 4486c86271 Fix rendering artefacts after double-width characters (e.g. "⚠️") on some terminals (#5198)
Fixes
https://github.com/jesseduffield/lazygit/issues/5171#issuecomment-3710757175.
2026-01-12 10:02:27 +01:00
Stefan Haller b44a369295 Bump tcell
This fixes rendering artefacts after double-width characters such as "⚠️" on
some terminals.
2026-01-12 09:57:50 +01:00
Stefan Haller 498bc457fc Don't log the "git ls-remote" call when opening a PR (#5195)
When opening a PR, a "git ls-remote" call would appear in the Command
log. This is confusing, it's an internal detail that is not interesting
for the user to see.
2026-01-11 20:04:54 +01:00
Stefan Haller 7f957f059b Don't log the "git ls-remote" call when opening a PR
When opening a PR, a "git ls-remote" call would appear in the Command log. This
is confusing, it's an internal detail that is not interesting for the user to
see.
2026-01-11 20:02:08 +01:00
Stefan Haller 1a4af359ec Request info about terminal in issue template (#5194)
For some types of bugs it can be relevant what terminal is being used,
so ask users to provide this information when they file an issue.
2026-01-11 20:00:34 +01:00
Stefan Haller f16bec2ce3 Ask users to provide information about their terminal 2026-01-11 19:56:47 +01:00
Stefan Haller 798fe4a1ef Remove git version
It's unnecessary to ask users to provide this; the output of `lazygit --version`
contains it already.
2026-01-11 19:56:47 +01:00
Stefan Haller bb2e5fa886 Update tcell dependency (#5192)
This provides two fixes:
- proper handling of keypad keys on certain terminals (e.g. iTerm2)
- fix problems pasting certain emojis or east asian text on Windows
Terminal

Fixes #5171
Fixes #5173
2026-01-11 18:30:51 +01:00
Stefan Haller 3067ff1cac bump tcell
This provides two fixes:
- proper handling of keypad keys on certain terminals (e.g. iTerm2)
- fix problems pasting certain emojis or east asian text on Windows Terminal
2026-01-11 18:15:54 +01:00
Stefan Haller 312feff130 Fix pasting multi-line text into the commit description (#5186)
This fixes a regression in 0.58.

Fixes #5181.
2026-01-11 18:10:17 +01:00
Stefan Haller ee60102fdc Bump gocui
This fixes pasting multi-line text into the commit message editor; see
https://github.com/jesseduffield/gocui/pull/96.
2026-01-11 18:07:30 +01:00
Stefan Haller 7b969a03ad When doing ctrl-f, and the resulting commit is not visible, scroll it into view (#5182)
This used to work in the last version, but broke with efd4298b5e
(#5134).
2026-01-11 18:01:54 +01:00
Stefan Haller 1f7e5d7767 When doing ctrl-f, and the resulting commit is not visible, scroll it into view
This used to work in the last version, but broke with efd4298b5e (#5134).
2026-01-06 16:17:33 +01:00
Stefan Haller 80dd695d7a Update search position (match x of y) when changing the selection in a list view (#5169)
When searching (not filtering) in a list view using `/`, moving the
selection now updates the "x of y" indicator of the search results to
the last one before the selection. Pressing `n` again searches from
there rather than from the previously shown result.

Also see corresponding gocui PR at
https://github.com/jesseduffield/gocui/pull/95.
2026-01-04 18:54:28 +01:00
Stefan Haller 65edd99fd0 Update search position (match x of y) when changing the selection in a list view
or in the staging view.
2026-01-04 18:51:45 +01:00
Stefan Haller 98bf8f86d1 Bump gocui and adapt code accordingly 2026-01-04 18:48:47 +01:00
21 changed files with 349 additions and 121 deletions
+3 -1
View File
@@ -26,7 +26,9 @@ If applicable, add screenshots to help explain your problem.
### Version info:
* _Run `lazygit --version` and paste the result here_
* _Run `git --version` and paste the result here_
### Terminal info:
What terminal are you using, and which version? For some types of bugs this information can be relevant.
### Additional context
Add any other context about the problem here.
+2 -2
View File
@@ -12,13 +12,13 @@ require (
github.com/aybabtme/humanlog v0.4.1
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.11
github.com/gdamore/tcell/v2 v2.13.5
github.com/gdamore/tcell/v2 v2.13.7
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.4.2
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd
github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985
github.com/jesseduffield/gocui v0.3.1-0.20260111170441-330357056207
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
+4 -4
View File
@@ -95,8 +95,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.5 h1:YvWYCSr6gr2Ovs84dXbZLjDuOfQchhj8buOEqY52rpA=
github.com/gdamore/tcell/v2 v2.13.5/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/gdamore/tcell/v2 v2.13.7 h1:yfHdeC7ODIYCc6dgRos8L1VujQtXHmUpU6UZotzD6os=
github.com/gdamore/tcell/v2 v2.13.7/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
@@ -189,8 +189,8 @@ github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2Paiis
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA=
github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985 h1:qdjGSiNnlGtoi+nzyERQJvee50JpJjeQ6sEhP7jCfMo=
github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ=
github.com/jesseduffield/gocui v0.3.1-0.20260111170441-330357056207 h1:YYXHnZVN9nt2UsBO6dYPVPRDVJuEVcGIzhM0SH9x4/Q=
github.com/jesseduffield/gocui v0.3.1-0.20260111170441-330357056207/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+1 -1
View File
@@ -85,6 +85,6 @@ func (self *RemoteCommands) GetRemoteURL(remoteName string) (string, error) {
Arg("--get-url", remoteName).
ToArgv()
url, err := self.cmd.New(cmdArgs).RunWithOutput()
url, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.TrimSpace(url), err
}
+2 -1
View File
@@ -68,7 +68,8 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect))
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
+14 -2
View File
@@ -24,6 +24,10 @@ type ListContextTrait struct {
// If renderOnlyVisibleLines is true, needRerenderVisibleLines indicates whether we need to
// rerender the visible lines e.g. because the scroll position changed
needRerenderVisibleLines bool
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search
// result index.
inOnSearchSelect bool
}
func (self *ListContextTrait) IsListContext() {}
@@ -31,6 +35,10 @@ func (self *ListContextTrait) IsListContext() {}
func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.Context.FocusLine(scrollIntoView)
// Need to capture this in a local variable because by the time the AfterLayout function runs,
// the field will have been reset to false already
inOnSearchSelect := self.inOnSearchSelect
// Doing this at the end of the layout function because we need the view to be
// resized before we focus the line, otherwise if we're in accordion mode
// the view could be squashed and won't know how to adjust the cursor/origin.
@@ -40,6 +48,9 @@ func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.GetViewTrait().FocusPoint(
self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()), scrollIntoView)
if !inOnSearchSelect {
self.GetView().SetNearestSearchPosition()
}
selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx()
if isSelectingRange {
@@ -117,10 +128,11 @@ func (self *ListContextTrait) HandleRender() {
self.setFooter()
}
func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) error {
func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) {
self.GetList().SetSelection(self.ViewIndexToModelIndex(selectedLineIdx))
self.inOnSearchSelect = true
self.HandleFocus(types.OnFocusOpts{})
return nil
self.inOnSearchSelect = false
}
func (self *ListContextTrait) IsItemVisible(item types.HasUrn) bool {
+2 -1
View File
@@ -134,7 +134,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect))
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
+2 -1
View File
@@ -31,7 +31,8 @@ func NewMainContext(
SearchTrait: NewSearchTrait(c),
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(int) error { return nil }))
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(func(int) {})
return ctx
}
+16 -8
View File
@@ -16,6 +16,10 @@ type PatchExplorerContext struct {
getIncludedLineIndices func() []int
c *ContextCommon
mutex deadlock.Mutex
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
// search result index.
inOnSelectItemCallback bool
}
var (
@@ -49,14 +53,14 @@ func NewPatchExplorerContext(
SearchTrait: NewSearchTrait(c),
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(
func(selectedLineIdx int) error {
ctx.GetMutex().Lock()
defer ctx.GetMutex().Unlock()
ctx.NavigateTo(selectedLineIdx)
return nil
}),
)
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(func(selectedLineIdx int) {
ctx.GetMutex().Lock()
defer ctx.GetMutex().Unlock()
ctx.inOnSelectItemCallback = true
ctx.NavigateTo(selectedLineIdx)
ctx.inOnSelectItemCallback = false
})
ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged)
@@ -113,6 +117,10 @@ func (self *PatchExplorerContext) FocusSelection() {
// As far as the view is concerned, we are always selecting a range
view.SetRangeSelectStart(startIdx)
view.SetCursorY(endIdx - newOriginY)
if !self.inOnSelectItemCallback {
view.SetNearestSearchPosition()
}
}
func (self *PatchExplorerContext) GetContentToRender() string {
-14
View File
@@ -36,20 +36,6 @@ func (self *SearchTrait) ClearSearchString() {
// used for type switch
func (self *SearchTrait) IsSearchableContext() {}
func (self *SearchTrait) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error {
return func(selectedLineIdx int, index int, total int) error {
self.RenderSearchStatus(index, total)
if total != 0 {
if err := innerFunc(selectedLineIdx); err != nil {
return err
}
}
return nil
}
}
func (self *SearchTrait) RenderSearchStatus(index int, total int) {
keybindingConfig := self.c.UserConfig().Keybinding
+2 -1
View File
@@ -134,7 +134,8 @@ func NewSubCommitsContext(
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect))
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
+2 -1
View File
@@ -56,7 +56,8 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect))
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
@@ -137,6 +137,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
}
self.c.Contexts().LocalCommits.SetSelection(index)
self.c.Contexts().LocalCommits.FocusLine(true)
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
},
+4 -9
View File
@@ -108,20 +108,15 @@ func (self *SearchHelper) Confirm() error {
return self.CancelPrompt()
}
var err error
switch state.SearchType() {
case types.SearchTypeFilter:
self.ConfirmFilter()
case types.SearchTypeSearch:
err = self.ConfirmSearch()
self.ConfirmSearch()
case types.SearchTypeNone:
self.c.Context().Pop()
}
if err != nil {
return err
}
return self.c.ResetKeybindings()
}
@@ -144,13 +139,13 @@ func (self *SearchHelper) ConfirmFilter() {
self.c.Context().Pop()
}
func (self *SearchHelper) ConfirmSearch() error {
func (self *SearchHelper) ConfirmSearch() {
state := self.searchState()
context, ok := state.Context.(types.ISearchableContext)
if !ok {
self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey())
return nil
return
}
searchString := self.promptContent()
@@ -161,7 +156,7 @@ func (self *SearchHelper) ConfirmSearch() error {
self.c.Context().Pop()
return context.GetView().Search(searchString, modelSearchResults(context))
context.GetView().Search(searchString, modelSearchResults(context))
}
func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition {
+52
View File
@@ -11,6 +11,10 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// Creating a branch avoids that searching for 't' will unexpectedly match the first commit
// (since it finds it in the extra info line, which is "HEAD -> master")
shell.NewBranch("branch")
shell.EmptyCommit("one")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
@@ -103,6 +107,54 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{
Contains("three"),
Contains("two"),
Contains("one").IsSelected(),
).
NavigateToLine(Contains("three")).
Tap(func() {
t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)"))
}).
Press("N").
Tap(func() {
t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)"))
}).
Lines(
Contains("four").IsSelected(),
Contains("three"),
Contains("two"),
Contains("one"),
).
Press(keys.Universal.StartSearch).
Tap(func() {
t.ExpectSearch().
Type("t").
Confirm()
t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)"))
}).
Lines(
Contains("four"),
Contains("three").IsSelected(),
Contains("two"),
Contains("one"),
).
SelectPreviousItem().
Tap(func() {
t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)"))
}).
Lines(
Contains("four").IsSelected(),
Contains("three"),
Contains("two"),
Contains("one"),
).
Press("n").
Tap(func() {
t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)"))
}).
Lines(
Contains("four"),
Contains("three").IsSelected(),
Contains("two"),
Contains("one"),
)
},
})
+83 -41
View File
@@ -84,6 +84,7 @@ type inputProcessor struct {
evch chan<- Event
rows int // used for clipping mouse coordinates
cols int // used for clipping mouse coordinates
surrogate rune
nested *inputProcessor
}
@@ -135,8 +136,9 @@ type csiParamMode struct {
}
type keyMap struct {
Key Key
Mod ModMask
Key Key
Mod ModMask
Rune rune
}
var csiAllKeys = map[csiParamMode]keyMap{
@@ -275,41 +277,70 @@ var csiAllKeys = map[csiParamMode]keyMap{
}
// keys reported using Kitty csi-u protocol
var csiUKeys = map[int]Key{
27: KeyESC,
9: KeyTAB,
13: KeyEnter,
127: KeyBS,
57358: KeyCapsLock,
57359: KeyScrollLock,
57360: KeyNumLock,
57361: KeyPrint,
57362: KeyPause,
57363: KeyMenu,
57376: KeyF13,
57377: KeyF14,
57378: KeyF15,
57379: KeyF16,
57380: KeyF17,
57381: KeyF18,
57382: KeyF19,
57383: KeyF20,
57384: KeyF21,
57385: KeyF22,
57386: KeyF23,
57387: KeyF24,
57388: KeyF25,
57389: KeyF26,
57390: KeyF27,
57391: KeyF28,
57392: KeyF29,
57393: KeyF30,
57394: KeyF31,
57395: KeyF32,
57396: KeyF33,
57397: KeyF34,
57398: KeyF35,
// TODO: KP keys
var csiUKeys = map[int]keyMap{
27: {Key: KeyESC},
9: {Key: KeyTAB},
13: {Key: KeyEnter},
127: {Key: KeyBS},
57358: {Key: KeyCapsLock},
57359: {Key: KeyScrollLock},
57360: {Key: KeyNumLock},
57361: {Key: KeyPrint},
57362: {Key: KeyPause},
57363: {Key: KeyMenu},
57376: {Key: KeyF13},
57377: {Key: KeyF14},
57378: {Key: KeyF15},
57379: {Key: KeyF16},
57380: {Key: KeyF17},
57381: {Key: KeyF18},
57382: {Key: KeyF19},
57383: {Key: KeyF20},
57384: {Key: KeyF21},
57385: {Key: KeyF22},
57386: {Key: KeyF23},
57387: {Key: KeyF24},
57388: {Key: KeyF25},
57389: {Key: KeyF26},
57390: {Key: KeyF27},
57391: {Key: KeyF28},
57392: {Key: KeyF29},
57393: {Key: KeyF30},
57394: {Key: KeyF31},
57395: {Key: KeyF32},
57396: {Key: KeyF33},
57397: {Key: KeyF34},
57398: {Key: KeyF35},
57399: {Key: KeyRune, Rune: '0'}, // KP 0
57400: {Key: KeyRune, Rune: '1'}, // KP 1
57401: {Key: KeyRune, Rune: '2'}, // KP 2
57402: {Key: KeyRune, Rune: '3'}, // KP 3
57403: {Key: KeyRune, Rune: '4'}, // KP 4
57404: {Key: KeyRune, Rune: '5'}, // KP 5
57405: {Key: KeyRune, Rune: '6'}, // KP 6
57406: {Key: KeyRune, Rune: '7'}, // KP 7
57407: {Key: KeyRune, Rune: '8'}, // KP 8
57408: {Key: KeyRune, Rune: '9'}, // KP 9
57409: {Key: KeyRune, Rune: '.'}, // KP_DECIMAL
57410: {Key: KeyRune, Rune: '/'}, // KP_DIVIDE
57411: {Key: KeyRune, Rune: '*'}, // KP_MULTIPLY
57412: {Key: KeyRune, Rune: '-'}, // KP_SUBTRACT
57413: {Key: KeyRune, Rune: '+'}, // KP_ADD
57414: {Key: KeyEnter}, // KP_ENTER
57415: {Key: KeyRune, Rune: '='}, // KP_EQUAL
57416: {Key: KeyClear}, // KP_SEPARATOR
57417: {Key: KeyLeft}, // KP_LEFT
57418: {Key: KeyRight}, // KP_RIGHT
57419: {Key: KeyUp}, // KP_UP
57420: {Key: KeyDown}, // KP_DOWN
57421: {Key: KeyPgUp}, // KP_PG_UP
57422: {Key: KeyPgDn}, // KP_PG_DN
57423: {Key: KeyHome}, // KP_HOME
57424: {Key: KeyEnd}, // KP_END
57425: {Key: KeyInsert}, // KP_INSERT
57426: {Key: KeyDelete}, // KP_DELETE
// 57427: {Key: KeyBegin}, // KP_BEGIN
// TODO: Media keys
}
@@ -318,8 +349,8 @@ var winKeys = map[int]Key{
0x03: KeyCancel, // vkCancel
0x08: KeyBackspace, // vkBackspace
0x09: KeyTab, // vkTab
0x0c: KeyClear, // vClear
0x0d: KeyEnter, // vkReturn
0x12: KeyClear, // vClear
0x13: KeyPause, // vkPause
0x1b: KeyEscape, // vkEscape
0x21: KeyPgUp, // vkPrior
@@ -701,11 +732,22 @@ func (ip *inputProcessor) handleWinKey(P []int) {
} else if chr < ' ' && P[0] >= 0x41 && P[0] <= 0x5a {
key = Key(P[0])
chr = 0
} else if key == 0x11 || key == 0x13 || key == 0x14 {
} else if chr >= 0xD800 && chr <= 0xDBFF {
// high surrogate pair
ip.surrogate = chr
return
} else if chr >= 0xDC00 && chr <= 0xDFFF {
// low surrogate pair
chr = utf16.DecodeRune(ip.surrogate, chr)
} else if P[0] == 0x10 || P[0] == 0x11 || P[0] == 0x12 || P[0] == 0x14 {
// lone modifiers
ip.surrogate = 0
return
}
ip.surrogate = 0
// Modifiers
if P[4]&0x010 != 0 {
mod |= ModShift
@@ -791,8 +833,8 @@ func (ip *inputProcessor) handleCsi(mode rune, params []byte, intermediate []byt
key := KeyRune
chr := rune(0)
if k1, ok := csiUKeys[P0]; ok {
key = k1
chr = 0
key = k1.Key
chr = k1.Rune
} else {
chr = rune(P0)
}
+8
View File
@@ -622,6 +622,14 @@ func (t *tScreen) drawCell(x, y int) int {
width = 1
str = " "
}
if width > 1 {
// Clobber over any content in the next cell.
// This fixes a problem with some terminals where overwriting two
// adjacent single cells with a wide rune would leave an image
// of the second cell. This is a workaround for buggy terminals.
t.writeString(" \b\b")
}
t.writeString(str)
t.cx += width
t.cells.SetDirty(x, y, false)
+23 -11
View File
@@ -1,4 +1,4 @@
// Copyright 2025 The TCell Authors
// Copyright 2026 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
@@ -72,6 +72,7 @@ type winTty struct {
oomode uint32 // original output mode
oscreen consoleInfo
wg sync.WaitGroup
surrogate rune
sync.Mutex
}
@@ -126,7 +127,7 @@ func (w *winTty) Drain() error {
func (w *winTty) getConsoleInput() error {
// cancelFlag comes first as WaitForMultipleObjects returns the lowest index
// in the event that both events are signalled.
// in the event that both events are signaled.
waitObjects := []syscall.Handle{w.cancelFlag, w.in}
// As arrays are contiguous in memory, a pointer to the first object is the
@@ -158,19 +159,30 @@ func (w *winTty) getConsoleInput() error {
if rv == 0 {
return er
}
loop:
for i := range nrec {
ir := rec[i]
switch ir.typ {
case keyEvent:
chr := ir.data[10] // we only see ASCII, key down events in VT mode
// because we use win32-input-mode, we will only
// see US-ASCII characters - (Q: will they be
// 16-bit values with possible surrogate pairs?)
select {
case w.buf <- chr:
case <-w.stopQ:
break
// we normally only expect to see ascii, but paste data may come in as UTF-16.
wc := rune(binary.LittleEndian.Uint16(ir.data[10:]))
if wc >= 0xD800 && wc <= 0xDBFF {
// if it was a high surrogate, which happens for pasted UTF-16,
// then save it until we get the low and can decode it.
w.surrogate = wc
continue
} else if wc >= 0xDC00 && wc <= 0xDFFF {
wc = utf16.DecodeRune(w.surrogate, wc)
}
w.surrogate = 0
for _, chr := range []byte(string(wc)) {
// We normally expect only to see ASCII (win32-input-mode),
// but apparently pasted data can arrive in UTF-16 here.
select {
case w.buf <- chr:
case <-w.stopQ:
break loop
}
}
case resizeEvent:
+2 -2
View File
@@ -1313,9 +1313,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
// back to '\n' fixes pasting multi-line text from Ghostty, and doesn't
// seem harmful for other terminal emulators.
//
// KeyCtrlJ (int value 10) is '\r', and KeyCtrlM (int value 13) is '\n'.
// KeyCtrlJ (int value 10) is '\r'.
if g.IsPasting && ev.Key == KeyCtrlJ {
ev.Key = KeyCtrlM
ev.Key = KeyEnter
}
err := g.execKeybindings(g.currentView, ev)
+124 -19
View File
@@ -217,29 +217,53 @@ type searcher struct {
searchPositions []SearchPosition
modelSearchResults []SearchPosition
currentSearchIndex int
onSelectItem func(int, int, int) error
onSelectItem func(int)
renderSearchStatus func(int, int)
}
func (v *View) SetOnSelectItem(onSelectItem func(int, int, int) error) {
func (v *View) SetRenderSearchStatus(renderSearchStatus func(int, int)) {
v.searcher.renderSearchStatus = renderSearchStatus
}
func (v *View) SetOnSelectItem(onSelectItem func(int)) {
v.searcher.onSelectItem = onSelectItem
}
func (v *View) renderSearchStatus(index int, itemCount int) {
if v.searcher.renderSearchStatus != nil {
v.searcher.renderSearchStatus(index, itemCount)
}
}
func (v *View) gotoNextMatch() error {
if len(v.searcher.searchPositions) == 0 {
return nil
}
if v.Highlight && v.oy+v.cy < v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
// If the selection is before the current match, just jump to the current match and return.
// This can only happen if the user has moved the cursor to before the first match.
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 {
v.searcher.currentSearchIndex = 0
} else {
v.searcher.currentSearchIndex++
}
return v.SelectSearchResult(v.searcher.currentSearchIndex)
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
func (v *View) gotoPreviousMatch() error {
if len(v.searcher.searchPositions) == 0 {
return nil
}
if v.Highlight && v.oy+v.cy > v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
// If the selection is after the current match, just jump to the current match and return.
// This happens if the user has moved the cursor down from the current match.
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
if v.searcher.currentSearchIndex == 0 {
if len(v.searcher.searchPositions) > 0 {
v.searcher.currentSearchIndex = len(v.searcher.searchPositions) - 1
@@ -247,13 +271,14 @@ func (v *View) gotoPreviousMatch() error {
} else {
v.searcher.currentSearchIndex--
}
return v.SelectSearchResult(v.searcher.currentSearchIndex)
v.SelectSearchResult(v.searcher.currentSearchIndex)
return nil
}
func (v *View) SelectSearchResult(index int) error {
func (v *View) SelectSearchResult(index int) {
itemCount := len(v.searcher.searchPositions)
if itemCount == 0 {
return nil
return
}
if index > itemCount-1 {
index = itemCount - 1
@@ -262,10 +287,10 @@ func (v *View) SelectSearchResult(index int) error {
y := v.searcher.searchPositions[index].Y
v.FocusPoint(v.ox, y, true)
v.renderSearchStatus(index, itemCount)
if v.searcher.onSelectItem != nil {
return v.searcher.onSelectItem(y, index, itemCount)
v.searcher.onSelectItem(y)
}
return nil
}
// Returns <current match index>, <total matches>
@@ -294,26 +319,29 @@ func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPositi
if len(v.searcher.searchPositions) > 0 {
// get the first result past the current cursor
currentIndex := 0
adjustedY := v.oy + v.cy
adjustedX := v.ox + v.cx
for i, pos := range v.searcher.searchPositions {
if pos.Y > adjustedY || (pos.Y == adjustedY && pos.XStart > adjustedX) {
currentIndex = i
break
if v.Highlight {
// ...but only if we're showing the highlighted line
adjustedY := v.oy + v.cy
adjustedX := v.ox + v.cx
for i, pos := range v.searcher.searchPositions {
if pos.Y > adjustedY || (pos.Y == adjustedY && pos.XStart > adjustedX) {
currentIndex = i
break
}
}
}
v.searcher.currentSearchIndex = currentIndex
}
}
func (v *View) Search(str string, modelSearchResults []SearchPosition) error {
func (v *View) Search(str string, modelSearchResults []SearchPosition) {
v.UpdateSearchResults(str, modelSearchResults)
if len(v.searcher.searchPositions) > 0 {
return v.SelectSearchResult(v.searcher.currentSearchIndex)
v.SelectSearchResult(v.searcher.currentSearchIndex)
} else {
v.renderSearchStatus(0, 0)
}
return v.searcher.onSelectItem(-1, -1, 0)
}
func (v *View) ClearSearch() {
@@ -324,8 +352,37 @@ func (v *View) IsSearching() bool {
return v.searcher.searchString != ""
}
func (v *View) nearestSearchPosition() int {
currentLineIndex := v.cy + v.oy
lastSearchPos := 0
for i, pos := range v.searcher.searchPositions {
if pos.Y == currentLineIndex {
return i
}
if pos.Y > currentLineIndex {
break
}
lastSearchPos = i
}
return lastSearchPos
}
func (v *View) SetNearestSearchPosition() {
if len(v.searcher.searchPositions) > 0 {
newPos := v.nearestSearchPosition()
if newPos != v.searcher.currentSearchIndex {
v.searcher.currentSearchIndex = newPos
v.renderSearchStatus(newPos, len(v.searcher.searchPositions))
}
}
}
func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) {
lineCount := len(v.lines)
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
lineCount := len(v.viewLines)
if cy < 0 || cy > lineCount {
return
}
@@ -1757,6 +1814,52 @@ func (v *View) setContentLineCount(lineCount int) {
v.lines = v.lines[:lineCount]
}
// If the current search result is no longer visible after a scroll up, select the last search
// result that is visible in the view, if any, or the first one that is below the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollUp() {
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
windowBottom := v.oy + v.InnerHeight()
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom {
newSearchIndex := v.searcher.currentSearchIndex
for newSearchIndex > 0 &&
v.searcher.searchPositions[newSearchIndex-1].Y >= v.oy {
newSearchIndex--
if v.searcher.searchPositions[newSearchIndex].Y < windowBottom {
break
}
}
if v.searcher.currentSearchIndex != newSearchIndex {
v.searcher.currentSearchIndex = newSearchIndex
v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
}
}
}
}
// If the current search result is no longer visible after a scroll down, select the first search
// result that is visible in the view, if any, or the last one that is above the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollDown() {
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy {
newSearchIndex := v.searcher.currentSearchIndex
windowBottom := v.oy + v.InnerHeight()
for newSearchIndex+1 < len(v.searcher.searchPositions) &&
v.searcher.searchPositions[newSearchIndex+1].Y < windowBottom {
newSearchIndex++
if v.searcher.searchPositions[newSearchIndex].Y >= v.oy {
break
}
}
if v.searcher.currentSearchIndex != newSearchIndex {
v.searcher.currentSearchIndex = newSearchIndex
v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
}
}
}
}
func (v *View) ScrollUp(amount int) {
if amount > v.oy {
amount = v.oy
@@ -1767,6 +1870,7 @@ func (v *View) ScrollUp(amount int) {
v.cy += amount
v.clearHover()
v.selectVisibleSearchResultAfterScrollUp()
}
}
@@ -1778,6 +1882,7 @@ func (v *View) ScrollDown(amount int) {
v.cy -= adjustedAmount
v.clearHover()
v.selectVisibleSearchResultAfterScrollDown()
}
}
+2 -2
View File
@@ -88,7 +88,7 @@ github.com/fatih/color
# github.com/gdamore/encoding v1.0.1
## explicit; go 1.9
github.com/gdamore/encoding
# github.com/gdamore/tcell/v2 v2.13.5
# github.com/gdamore/tcell/v2 v2.13.7
## explicit; go 1.24.0
github.com/gdamore/tcell/v2
github.com/gdamore/tcell/v2/terminfo
@@ -211,7 +211,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame
github.com/jesseduffield/go-git/v5/utils/merkletrie/noder
github.com/jesseduffield/go-git/v5/utils/sync
github.com/jesseduffield/go-git/v5/utils/trace
# github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985
# github.com/jesseduffield/gocui v0.3.1-0.20260111170441-330357056207
## explicit; go 1.25
github.com/jesseduffield/gocui
# github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5