diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 8b5919c3c78..a126c2da1c6 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -259,7 +259,7 @@ func underlineLinks(text string) string { } else { linkEnd += linkStart } - underlinedLink := style.PrintSimpleHyperlink(remaining[linkStart:linkEnd]) + underlinedLink := style.PrintSimpleHyperlink(remaining[linkStart:linkEnd], true) result += remaining[:linkStart] + underlinedLink remaining = remaining[linkEnd:] } diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index ab7a6a0d507..3e06fc9db5c 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -210,12 +210,12 @@ func (self *StatusController) showDashboard() error { []string{ lazygitTitle(), fmt.Sprintf("Copyright %d Jesse Duffield", time.Now().Year()), - fmt.Sprintf("Keybindings: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr))), - fmt.Sprintf("Config Options: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Config, versionStr))), - fmt.Sprintf("Tutorial: %s", style.PrintSimpleHyperlink(constants.Links.Docs.Tutorial)), - fmt.Sprintf("Raise an Issue: %s", style.PrintSimpleHyperlink(constants.Links.Issues)), - fmt.Sprintf("Release Notes: %s", style.PrintSimpleHyperlink(constants.Links.Releases)), - style.FgMagenta.Sprintf("Become a sponsor: %s", style.PrintSimpleHyperlink(constants.Links.Donate)), // caffeine ain't free + fmt.Sprintf("Keybindings: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr), true)), + fmt.Sprintf("Config Options: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Config, versionStr), true)), + fmt.Sprintf("Tutorial: %s", style.PrintSimpleHyperlink(constants.Links.Docs.Tutorial, true)), + fmt.Sprintf("Raise an Issue: %s", style.PrintSimpleHyperlink(constants.Links.Issues, true)), + fmt.Sprintf("Release Notes: %s", style.PrintSimpleHyperlink(constants.Links.Releases, true)), + style.FgMagenta.Sprintf("Become a sponsor: %s", style.PrintSimpleHyperlink(constants.Links.Donate, true)), // caffeine ain't free }, "\n\n") + "\n" return self.c.RenderToMainViews(types.RefreshMainOpts{ diff --git a/pkg/gui/information_panel.go b/pkg/gui/information_panel.go index 03e4dd8788e..b8ce150b764 100644 --- a/pkg/gui/information_panel.go +++ b/pkg/gui/information_panel.go @@ -14,8 +14,8 @@ func (gui *Gui) informationStr() string { } if gui.g.Mouse { - donate := style.FgMagenta.Sprint(style.PrintHyperlink(gui.c.Tr.Donate, constants.Links.Donate)) - askQuestion := style.FgYellow.Sprint(style.PrintHyperlink(gui.c.Tr.AskQuestion, constants.Links.Discussions)) + donate := style.FgMagenta.Sprint(style.PrintHyperlink(gui.c.Tr.Donate, constants.Links.Donate, true)) + askQuestion := style.FgYellow.Sprint(style.PrintHyperlink(gui.c.Tr.AskQuestion, constants.Links.Discussions, true)) return fmt.Sprintf("%s %s %s", donate, askQuestion, gui.Config.GetVersion()) } else { return gui.Config.GetVersion() diff --git a/pkg/gui/style/hyperlink.go b/pkg/gui/style/hyperlink.go index 0585e89a957..6974a48ce0c 100644 --- a/pkg/gui/style/hyperlink.go +++ b/pkg/gui/style/hyperlink.go @@ -3,11 +3,19 @@ package style import "fmt" // Render the given text as an OSC 8 hyperlink -func PrintHyperlink(text string, link string) string { - return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, text) +func PrintHyperlink(text string, link string, underline bool) string { + result := fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, text) + if underline { + return AttrUnderline.Sprint(result) + } + return result } // Render a link where the text is the same as a link -func PrintSimpleHyperlink(link string) string { - return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, link) +func PrintSimpleHyperlink(link string, underline bool) string { + result := fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, link) + if underline { + return AttrUnderline.Sprint(result) + } + return result } diff --git a/pkg/utils/color_test.go b/pkg/utils/color_test.go index 19770d63e74..e2062d97bc1 100644 --- a/pkg/utils/color_test.go +++ b/pkg/utils/color_test.go @@ -192,7 +192,11 @@ func TestDecolorise(t *testing.T) { output: "ta", }, { - input: "a_" + style.PrintSimpleHyperlink("xyz") + "_b", + input: "a_" + style.PrintSimpleHyperlink("xyz", true) + "_b", + output: "a_xyz_b", + }, + { + input: "a_" + style.PrintSimpleHyperlink("xyz", false) + "_b", output: "a_xyz_b", }, } diff --git a/vendor/github.com/jesseduffield/gocui/gui.go b/vendor/github.com/jesseduffield/gocui/gui.go index 9d848d93ded..c239d25a519 100644 --- a/vendor/github.com/jesseduffield/gocui/gui.go +++ b/vendor/github.com/jesseduffield/gocui/gui.go @@ -180,6 +180,8 @@ type Gui struct { suspended bool taskManager *TaskManager + + lastHoverView *View } type NewGuiOpts struct { @@ -836,7 +838,7 @@ func (g *Gui) processRemainingEvents() error { // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { switch ev.Type { - case eventKey, eventMouse: + case eventKey, eventMouse, eventMouseMove: return g.onKey(ev) case eventError: return ev.Err @@ -1395,6 +1397,19 @@ func (g *Gui) onKey(ev *GocuiEvent) error { return err } + case eventMouseMove: + mx, my := ev.MouseX, ev.MouseY + v, err := g.VisibleViewByPosition(mx, my) + if err != nil { + break + } + if g.lastHoverView != nil && g.lastHoverView != v { + g.lastHoverView.lastHoverPosition = nil + g.lastHoverView.hoveredHyperlink = nil + } + g.lastHoverView = v + v.onMouseMove(mx, my) + default: } diff --git a/vendor/github.com/jesseduffield/gocui/tcell_driver.go b/vendor/github.com/jesseduffield/gocui/tcell_driver.go index 6665432c56a..96e816b2f95 100644 --- a/vendor/github.com/jesseduffield/gocui/tcell_driver.go +++ b/vendor/github.com/jesseduffield/gocui/tcell_driver.go @@ -176,6 +176,7 @@ const ( eventKey eventResize eventMouse + eventMouseMove // only used when no button is down, otherwise it's eventMouse eventFocus eventInterrupt eventError @@ -387,7 +388,11 @@ func (g *Gui) pollEvent() GocuiEvent { if !wheeling { switch dragState { case NOT_DRAGGING: - return GocuiEvent{Type: eventNone} + return GocuiEvent{ + Type: eventMouseMove, + MouseX: x, + MouseY: y, + } // if we haven't released the left mouse button and we've moved the cursor then we're dragging case MAYBE_DRAGGING: if x != lastX || y != lastY { diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index 8933c2c7464..d893700d67f 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -56,6 +56,13 @@ type View struct { // tained is true if the viewLines must be updated tainted bool + // the last position that the mouse was hovering over; nil if the mouse is outside of + // this view, or not hovering over a cell + lastHoverPosition *pos + + // the location of the hyperlink that the mouse is currently hovering over; nil if none + hoveredHyperlink *SearchPosition + // internal representation of the view's buffer. We will keep viewLines around // from a previous render until we explicitly set them to nil, allowing us to // render the same content twice without flicker. Wherever we want to render @@ -182,12 +189,17 @@ type View struct { CanScrollPastBottom bool } +type pos struct { + x, y int +} + // call this in the event of a view resize, or if you want to render new content // without the chance of old content still appearing, or if you want to remove // a line from the existing content func (v *View) clearViewLines() { v.tainted = true v.viewLines = nil + v.clearHover() } type searcher struct { @@ -532,6 +544,10 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) error { } } + if v.isHoveredHyperlink(x, y) { + fgColor |= AttrReverse + } + // Don't display NUL characters if ch == 0 { ch = ' ' @@ -756,6 +772,7 @@ func (v *View) WriteRunes(p []rune) { // writeRunes copies slice of runes into internal lines buffer. func (v *View) writeRunes(p []rune) { v.tainted = true + v.clearHover() // Fill with empty cells, if writing outside current view buffer v.makeWriteable(v.wx, v.wy) @@ -1164,9 +1181,6 @@ func (v *View) draw() error { if bgColor == ColorDefault { bgColor = v.BgColor } - if c.hyperlink != "" { - fgColor |= AttrUnderline - } if err := v.setRune(x, y, c.chr, fgColor, bgColor); err != nil { return err @@ -1236,6 +1250,15 @@ func (v *View) isPatternMatchedRune(x, y int) (bool, bool) { return false, false } +func (v *View) isHoveredHyperlink(x, y int) bool { + if v.hoveredHyperlink != nil { + adjustedY := y + v.oy + adjustedX := x + v.ox + return adjustedY == v.hoveredHyperlink.Y && adjustedX >= v.hoveredHyperlink.XStart && adjustedX < v.hoveredHyperlink.XEnd + } + return false +} + // realPosition returns the position in the internal buffer corresponding to the // point (x, y) of the view. func (v *View) realPosition(vx, vy int) (x, y int, err error) { @@ -1406,6 +1429,7 @@ func (v *View) SetHighlight(y int, on bool) error { } v.tainted = true v.lines[y] = cells + v.clearHover() return nil } @@ -1672,8 +1696,12 @@ func (v *View) ScrollUp(amount int) { amount = v.oy } - v.oy -= amount - v.cy += amount + if amount != 0 { + v.oy -= amount + v.cy += amount + + v.clearHover() + } } // ensures we don't scroll past the end of the view's content @@ -1682,6 +1710,8 @@ func (v *View) ScrollDown(amount int) { if adjustedAmount > 0 { v.oy += adjustedAmount v.cy -= adjustedAmount + + v.clearHover() } } @@ -1690,12 +1720,18 @@ func (v *View) ScrollLeft(amount int) { if newOx < 0 { newOx = 0 } - v.ox = newOx + if newOx != v.ox { + v.ox = newOx + + v.clearHover() + } } // not applying any limits to this func (v *View) ScrollRight(amount int) { v.ox += amount + + v.clearHover() } func (v *View) adjustDownwardScrollAmount(scrollHeight int) int { @@ -1769,3 +1805,49 @@ func containsColoredTextInLine(fgColorStr string, text string, line []cell) bool return strings.Contains(currentMatch, text) } + +func (v *View) onMouseMove(x int, y int) { + if v.Editable { + return + } + + // newCx and newCy are relative to the view port, i.e. to the visible area of the view + newCx := x - v.x0 - 1 + newCy := y - v.y0 - 1 + // newX and newY are relative to the view's content, independent of its scroll position + newX := newCx + v.ox + newY := newCy + v.oy + + if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 { + if v.lastHoverPosition == nil || v.lastHoverPosition.x != newX || v.lastHoverPosition.y != newY { + v.hoveredHyperlink = v.findHyperlinkAt(newX, newY) + } + v.lastHoverPosition = &pos{x: newX, y: newY} + } else { + v.lastHoverPosition = nil + v.hoveredHyperlink = nil + } +} + +func (v *View) findHyperlinkAt(x, y int) *SearchPosition { + linkStr := v.viewLines[y].line[x].hyperlink + if linkStr == "" { + return nil + } + + xStart := x + for xStart > 0 && v.viewLines[y].line[xStart-1].hyperlink == linkStr { + xStart-- + } + xEnd := x + 1 + for xEnd < len(v.viewLines[y].line) && v.viewLines[y].line[xEnd].hyperlink == linkStr { + xEnd++ + } + + return &SearchPosition{XStart: xStart, XEnd: xEnd, Y: y} +} + +func (v *View) clearHover() { + v.hoveredHyperlink = nil + v.lastHoverPosition = nil +}