diff --git a/README.md b/README.md index e3ca52a..1dd3ebe 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ There are surprisingly few candidates that meet that bar. - Cursor shape (block/beam/underline) synced live from Nvim's mode info. - Keyboard and mouse input, including scroll wheel, mapped faithfully to Nvim's own input protocol. +- Open visible HTTP and HTTPS links in the default browser with Cmd-click on + macOS or Ctrl-click on Linux and Windows. - Live window resizing. - A small, plain `config.toml` for font and Nvim-launch settings. - Nerd Font support. diff --git a/src/internal/app/app.go b/src/internal/app/app.go index 473f2ad..e89a533 100644 --- a/src/internal/app/app.go +++ b/src/internal/app/app.go @@ -81,6 +81,16 @@ type App struct { // and silently drops every release — leaving Nvim stuck in // mouse-held state. mouseBtn string + + // linkPress records that a modified primary-button press was consumed + // to open a URL, so its drag/release events do not reach Nvim alone. + linkPress bool + openURL func(string) error + + // hoverRow/hoverCol retain the pointer's base-grid cell so the hovered + // URL can be resolved again after every Nvim redraw. + hoverRow, hoverCol int + hovering bool } // Options controls how the editor window starts. @@ -97,6 +107,7 @@ func New(cfg config.Config, nvimArgs []string, options Options) *App { state: uistate.New(), ime: newIMEShadow(), policy: cfg.Editor.InputPolicy(), + openURL: openExternalURL, } } @@ -156,7 +167,7 @@ func (a *App) layout(gtx layout.Context) { } } - render.Frame(gtx, a.fonts, snap) + render.Frame(gtx, a.fonts, snap, a.hoveredLink(snap)) } func (a *App) syncGuiFont(guiFont string) { @@ -232,7 +243,7 @@ func InputFilters(tag event.Tag) []event.Filter { key.Filter{Focus: tag, Name: key.NameTab, Optional: anyModifier}, pointer.Filter{ Target: tag, - Kinds: pointer.Press | pointer.Release | pointer.Drag | pointer.Scroll, + Kinds: pointer.Press | pointer.Release | pointer.Drag | pointer.Move | pointer.Leave | pointer.Scroll, ScrollX: bigScroll, ScrollY: bigScroll, }, @@ -389,12 +400,24 @@ func (a *App) altOwnsKeyPath() bool { } func (a *App) onPointer(e pointer.Event) { - if a.proc == nil || a.fonts.Metrics.CellWidth == 0 { + if e.Kind == pointer.Leave { + a.hovering = false + return + } + if a.fonts.Metrics.CellWidth == 0 { return } col := int(e.Position.X) / a.fonts.Metrics.CellWidth row := int(e.Position.Y) / a.fonts.Metrics.CellHeight - mods := input.ModifierPrefix(a.mods.Modifiers(e.Modifiers)) + if e.Kind == pointer.Move { + a.hoverRow, a.hoverCol = row, col + a.hovering = true + } + if a.proc == nil { + return + } + modifiers := a.mods.Modifiers(e.Modifiers) + mods := input.ModifierPrefix(modifiers) // With ext_multigrid, editor content lives on grids 2+ placed via // win_pos, not on grid 1 (which is just chrome). Hit-test against @@ -405,6 +428,9 @@ func (a *App) onPointer(e pointer.Event) { if g, gr, gc, ok := uistate.HitTest(snap.Windows, row, col); ok { grid, gridRow, gridCol = g, gr, gc } + if a.handleLinkPointer(e, modifiers, snap, grid, gridRow, gridCol) { + return + } if e.Kind == pointer.Scroll { if action, ok := input.ScrollDirection(e); ok { diff --git a/src/internal/app/link.go b/src/internal/app/link.go new file mode 100644 index 0000000..8004dad --- /dev/null +++ b/src/internal/app/link.go @@ -0,0 +1,176 @@ +package editorapp + +import ( + "fmt" + "log" + "net/url" + "os/exec" + "regexp" + "runtime" + "strings" + + "gioui.org/io/key" + "gioui.org/io/pointer" + + "github.com/kgfly/SimpleNvimEditor/internal/render" + "github.com/kgfly/SimpleNvimEditor/internal/uistate" +) + +var visibleURLPattern = regexp.MustCompile(`(?i)https?://[A-Z0-9._~%!$&()*+,;=:@/?#\[\]-]+`) + +type visibleLink struct { + target string + startCol, endCol int +} + +func (a *App) handleLinkPointer(e pointer.Event, modifiers key.Modifiers, snap uistate.Snapshot, grid, row, col int) bool { + if a.linkPress { + switch e.Kind { + case pointer.Drag: + return true + case pointer.Release: + a.linkPress = false + return true + case pointer.Press: + a.linkPress = false + } + } + + if e.Kind != pointer.Press || + !e.Buttons.Contain(pointer.ButtonPrimary) || + !linkModifierHeld(runtime.GOOS, modifiers) { + return false + } + + gridView, ok := snap.Grids[grid] + if !ok { + return false + } + target, ok := urlAt(gridView, row, col) + if !ok { + return false + } + + a.linkPress = true + if a.openURL == nil { + return true + } + if err := a.openURL(target); err != nil { + log.Printf("open URL: %v", err) + } + return true +} + +func linkModifierHeld(goos string, modifiers key.Modifiers) bool { + if goos == "darwin" { + return modifiers.Contain(key.ModCommand) + } + return modifiers.Contain(key.ModCtrl) +} + +func urlAt(grid uistate.GridView, row, col int) (string, bool) { + link, ok := linkAt(grid, row, col) + return link.target, ok +} + +func linkAt(grid uistate.GridView, row, col int) (visibleLink, bool) { + if row < 0 || row >= len(grid.Data) || col < 0 || col >= len(grid.Data[row]) { + return visibleLink{}, false + } + + var text strings.Builder + byteColumns := make([]int, 0, len(grid.Data[row])) + for cellCol, cell := range grid.Data[row] { + text.WriteString(cell.Text) + for range len(cell.Text) { + byteColumns = append(byteColumns, cellCol) + } + } + + line := text.String() + for _, match := range visibleURLPattern.FindAllStringIndex(line, -1) { + target := strings.TrimRight(line[match[0]:match[1]], ".,;:!?)]}") + end := match[0] + len(target) + if end <= match[0] || byteColumns[match[0]] > col || byteColumns[end-1] < col { + continue + } + parsed, err := url.Parse(target) + if err != nil || parsed.Host == "" || + (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) { + continue + } + return visibleLink{ + target: target, + startCol: byteColumns[match[0]], + endCol: byteColumns[end-1] + 1, + }, true + } + return visibleLink{}, false +} + +func (a *App) hoveredLink(snap uistate.Snapshot) render.HoverLink { + if !a.hovering { + return render.HoverLink{} + } + grid, row, col := 1, a.hoverRow, a.hoverCol + if hitGrid, hitRow, hitCol, ok := uistate.HitTest(snap.Windows, row, col); ok { + grid, row, col = hitGrid, hitRow, hitCol + } + gridView, ok := snap.Grids[grid] + if !ok { + return render.HoverLink{} + } + link, ok := linkAt(gridView, row, col) + if !ok { + return render.HoverLink{} + } + return render.HoverLink{ + Active: true, + GridID: grid, + Row: row, + StartCol: link.startCol, + EndCol: link.endCol, + } +} + +func openExternalURL(target string) error { + return openExternalURLFor(runtime.GOOS, target, startExternalCommand) +} + +func openExternalURLFor(goos, target string, start func(string, ...string) error) error { + command, args, err := externalURLCommand(goos, target) + if err != nil { + return err + } + if err := start(command, args...); err != nil { + return fmt.Errorf("start %s: %w", command, err) + } + return nil +} + +func externalURLCommand(goos, target string) (string, []string, error) { + var command string + var args []string + switch goos { + case "darwin": + command, args = "open", []string{target} + case "linux": + command, args = "xdg-open", []string{target} + case "windows": + command, args = "rundll32", []string{"url.dll,FileProtocolHandler", target} + default: + return "", nil, fmt.Errorf("opening URLs is unsupported on %s", goos) + } + return command, args, nil +} + +func startExternalCommand(command string, args ...string) error { + cmd := exec.Command(command, args...) + if err := cmd.Start(); err != nil { + return err + } + if err := cmd.Process.Release(); err != nil { + return fmt.Errorf("release process: %w", err) + } + return nil +} diff --git a/src/internal/app/link_test.go b/src/internal/app/link_test.go new file mode 100644 index 0000000..c5f1b14 --- /dev/null +++ b/src/internal/app/link_test.go @@ -0,0 +1,290 @@ +package editorapp + +import ( + "errors" + "reflect" + "runtime" + "testing" + + "gioui.org/f32" + "gioui.org/io/key" + "gioui.org/io/pointer" + + "github.com/kgfly/SimpleNvimEditor/internal/config" + "github.com/kgfly/SimpleNvimEditor/internal/uistate" +) + +func TestURLAt(t *testing.T) { + tests := []struct { + name string + line string + col int + want string + }{ + {name: "https", line: "open https://example.com/docs?q=1#start", col: 18, want: "https://example.com/docs?q=1#start"}, + {name: "http with port", line: "http://localhost:8080/status", col: 10, want: "http://localhost:8080/status"}, + {name: "surrounding punctuation", line: "(https://example.com/docs).", col: 12, want: "https://example.com/docs"}, + {name: "unicode before URL", line: "文 https://example.com", col: 12, want: "https://example.com"}, + {name: "trailing punctuation is not link", line: "https://example.com.", col: 19}, + {name: "unsupported scheme", line: "file://example.com/a", col: 10}, + {name: "outside URL", line: "before https://example.com after", col: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := urlAt(gridView(test.line), 0, test.col) + if got != test.want || ok != (test.want != "") { + t.Fatalf("urlAt(%q, col %d) = %q, %v; want %q, %v", test.line, test.col, got, ok, test.want, test.want != "") + } + }) + } +} + +func TestLinkAtReportsCellRange(t *testing.T) { + link, ok := linkAt(gridView("文 https://example.com."), 0, 12) + if !ok { + t.Fatal("linkAt() did not find URL") + } + if link.target != "https://example.com" || link.startCol != 2 || link.endCol != 21 { + t.Fatalf("linkAt() = %+v, want target https://example.com in columns [2, 21)", link) + } +} + +func TestLinkModifierHeld(t *testing.T) { + tests := []struct { + name string + goos string + mods key.Modifiers + want bool + }{ + {name: "mac command", goos: "darwin", mods: key.ModCommand, want: true}, + {name: "mac control", goos: "darwin", mods: key.ModCtrl, want: false}, + {name: "linux control", goos: "linux", mods: key.ModCtrl, want: true}, + {name: "linux command", goos: "linux", mods: key.ModCommand, want: false}, + {name: "windows control", goos: "windows", mods: key.ModCtrl, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := linkModifierHeld(test.goos, test.mods); got != test.want { + t.Fatalf("linkModifierHeld(%q, %v) = %v, want %v", test.goos, test.mods, got, test.want) + } + }) + } +} + +func TestHandleLinkPointerOpensURLAndConsumesRelease(t *testing.T) { + const target = "https://example.com/docs" + a := New(config.Default(), nil, Options{}) + var opened string + a.openURL = func(value string) error { + opened = value + return nil + } + snap := uistate.Snapshot{Grids: map[int]uistate.GridView{2: gridView("open " + target)}} + modifiers := key.ModCtrl + if runtime.GOOS == "darwin" { + modifiers = key.ModCommand + } + + press := pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary} + if consumed := a.handleLinkPointer(press, modifiers, snap, 2, 0, 12); !consumed { + t.Fatal("modified press on URL was not consumed") + } + if opened != target { + t.Fatalf("opened URL = %q, want %q", opened, target) + } + if !a.linkPress { + t.Fatal("modified link press was not remembered") + } + + if consumed := a.handleLinkPointer(pointer.Event{Kind: pointer.Release}, 0, snap, 2, 0, 12); !consumed { + t.Fatal("release following link press was not consumed") + } + if a.linkPress { + t.Fatal("link press was not cleared after release") + } +} + +func TestHandleLinkPointerLeavesOrdinaryClickForNvim(t *testing.T) { + a := New(config.Default(), nil, Options{}) + snap := uistate.Snapshot{Grids: map[int]uistate.GridView{1: gridView("https://example.com")}} + press := pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary} + + if consumed := a.handleLinkPointer(press, 0, snap, 1, 0, 10); consumed { + t.Fatal("ordinary click on URL was consumed") + } +} + +func TestHandleLinkPointerIgnoresMissingGridAndPlainText(t *testing.T) { + a := New(config.Default(), nil, Options{}) + press := pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary} + modifiers := key.ModCtrl + if runtime.GOOS == "darwin" { + modifiers = key.ModCommand + } + + if consumed := a.handleLinkPointer(press, modifiers, uistate.Snapshot{}, 1, 0, 0); consumed { + t.Fatal("click in a missing grid was consumed") + } + snap := uistate.Snapshot{Grids: map[int]uistate.GridView{1: gridView("plain text")}} + if consumed := a.handleLinkPointer(press, modifiers, snap, 1, 0, 2); consumed { + t.Fatal("modified click on plain text was consumed") + } +} + +func TestHandleLinkPointerConsumesDragAndAllowsNewPress(t *testing.T) { + a := New(config.Default(), nil, Options{}) + a.linkPress = true + snap := uistate.Snapshot{Grids: map[int]uistate.GridView{1: gridView("plain text")}} + + if consumed := a.handleLinkPointer(pointer.Event{Kind: pointer.Drag}, 0, snap, 1, 0, 0); !consumed { + t.Fatal("drag following link press was not consumed") + } + press := pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary} + if consumed := a.handleLinkPointer(press, 0, snap, 1, 0, 0); consumed { + t.Fatal("new ordinary press was consumed") + } + if a.linkPress { + t.Fatal("new press did not clear stale link state") + } +} + +func TestHandleLinkPointerWithNilOrFailingOpener(t *testing.T) { + const target = "https://example.com" + snap := uistate.Snapshot{Grids: map[int]uistate.GridView{1: gridView(target)}} + press := pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary} + modifiers := key.ModCtrl + if runtime.GOOS == "darwin" { + modifiers = key.ModCommand + } + + for _, openURL := range []func(string) error{nil, func(string) error { return errors.New("launch failed") }} { + a := New(config.Default(), nil, Options{}) + a.openURL = openURL + if consumed := a.handleLinkPointer(press, modifiers, snap, 1, 0, 8); !consumed { + t.Fatal("valid link press was not consumed") + } + } +} + +func TestHoveredLinkResolvesPlacedGrid(t *testing.T) { + a := New(config.Default(), nil, Options{}) + a.hovering = true + a.hoverRow, a.hoverCol = 4, 12 + snap := uistate.Snapshot{ + Grids: map[int]uistate.GridView{2: gridView("go https://example.com now")}, + Windows: []uistate.Placement{{ + GridID: 2, + Row: 4, + Col: 5, + Width: 26, + Height: 1, + }}, + } + + hover := a.hoveredLink(snap) + if !hover.Active || hover.GridID != 2 || hover.Row != 0 || hover.StartCol != 3 || hover.EndCol != 22 { + t.Fatalf("hoveredLink() = %+v, want grid 2 row 0 columns [3, 22)", hover) + } + + snap.Grids[2] = gridView("redrawn plain text") + if hover := a.hoveredLink(snap); hover.Active { + t.Fatalf("hoveredLink() retained stale URL after redraw: %+v", hover) + } +} + +func TestOnPointerTracksMoveAndLeaveWithoutNvim(t *testing.T) { + a := New(config.Default(), nil, Options{}) + a.fonts.Metrics.CellWidth = 8 + a.fonts.Metrics.CellHeight = 16 + a.onPointer(pointer.Event{Kind: pointer.Move, Position: f32.Pt(28, 36)}) + if !a.hovering || a.hoverRow != 2 || a.hoverCol != 3 { + t.Fatalf("move stored hovering=%v row=%d col=%d, want true, 2, 3", a.hovering, a.hoverRow, a.hoverCol) + } + + a.onPointer(pointer.Event{Kind: pointer.Leave}) + if a.hovering { + t.Fatal("leave did not clear hover state") + } +} + +func TestInputFiltersRequestHoverEvents(t *testing.T) { + for _, filter := range InputFilters(new(int)) { + pointerFilter, ok := filter.(pointer.Filter) + if !ok { + continue + } + if pointerFilter.Kinds&pointer.Move == 0 || pointerFilter.Kinds&pointer.Leave == 0 { + t.Fatalf("pointer filter kinds %v do not include move and leave", pointerFilter.Kinds) + } + return + } + t.Fatal("InputFilters() returned no pointer filter") +} + +func TestExternalURLCommand(t *testing.T) { + const target = "https://example.com/a?b=1" + tests := []struct { + goos string + wantCommand string + wantArgs []string + wantError bool + }{ + {goos: "darwin", wantCommand: "open", wantArgs: []string{target}}, + {goos: "linux", wantCommand: "xdg-open", wantArgs: []string{target}}, + {goos: "windows", wantCommand: "rundll32", wantArgs: []string{"url.dll,FileProtocolHandler", target}}, + {goos: "plan9", wantError: true}, + } + + for _, test := range tests { + t.Run(test.goos, func(t *testing.T) { + command, args, err := externalURLCommand(test.goos, target) + if (err != nil) != test.wantError { + t.Fatalf("externalURLCommand() error = %v, wantError %v", err, test.wantError) + } + if command != test.wantCommand || !reflect.DeepEqual(args, test.wantArgs) { + t.Fatalf("externalURLCommand() = %q, %v; want %q, %v", command, args, test.wantCommand, test.wantArgs) + } + }) + } +} + +func TestOpenExternalURLFor(t *testing.T) { + const target = "https://example.com" + var gotCommand string + var gotArgs []string + err := openExternalURLFor("linux", target, func(command string, args ...string) error { + gotCommand = command + gotArgs = args + return nil + }) + if err != nil { + t.Fatalf("openExternalURLFor() error = %v", err) + } + if gotCommand != "xdg-open" || !reflect.DeepEqual(gotArgs, []string{target}) { + t.Fatalf("launcher received %q, %v", gotCommand, gotArgs) + } + + launchErr := errors.New("launch failed") + if err := openExternalURLFor("linux", target, func(string, ...string) error { return launchErr }); !errors.Is(err, launchErr) { + t.Fatalf("openExternalURLFor() error = %v, want wrapped launch error", err) + } + if err := openExternalURLFor("plan9", target, func(string, ...string) error { return nil }); err == nil { + t.Fatal("openExternalURLFor() accepted unsupported OS") + } +} + +func TestStartExternalCommandReportsStartFailure(t *testing.T) { + if err := startExternalCommand("simplenvim-command-that-does-not-exist"); err == nil { + t.Fatal("startExternalCommand() did not report a missing executable") + } +} + +func gridView(line string) uistate.GridView { + cells := make([]uistate.Cell, 0, len(line)) + for _, char := range line { + cells = append(cells, uistate.Cell{Text: string(char)}) + } + return uistate.GridView{Rows: 1, Cols: len(cells), Data: [][]uistate.Cell{cells}} +} diff --git a/src/internal/render/glyphcache_test.go b/src/internal/render/glyphcache_test.go index 5b725a1..532e58a 100644 --- a/src/internal/render/glyphcache_test.go +++ b/src/internal/render/glyphcache_test.go @@ -194,6 +194,41 @@ func TestFrameIsDeterministic(t *testing.T) { } } +func TestFrameUnderlinesHoveredLinkRange(t *testing.T) { + px := image.Pt(200, 60) + fonts := testFonts(t, 1, px) + state := uistate.New() + state.Apply([][]interface{}{ + {"grid_resize", []interface{}{1, 10, 2}}, + }) + snap := state.Snapshot() + hover := HoverLink{Active: true, GridID: 1, Row: 0, StartCol: 2, EndCol: 5} + + plain := rasterize(t, px, func(gtx layout.Context) { + Frame(gtx, fonts, snap) + }) + underlined := rasterize(t, px, func(gtx layout.Context) { + Frame(gtx, fonts, snap, hover) + }) + y := min(fonts.Metrics.CellHeight-fonts.Metrics.Baseline+1, fonts.Metrics.CellHeight-1) + for col := hover.StartCol; col < hover.EndCol; col++ { + x := col*fonts.Metrics.CellWidth + fonts.Metrics.CellWidth/2 + if underlined.RGBAAt(x, y) == plain.RGBAAt(x, y) { + t.Errorf("hover did not change underline pixel in column %d", col) + } + } + x := fonts.Metrics.CellWidth / 2 + if underlined.RGBAAt(x, y) != plain.RGBAAt(x, y) { + t.Error("hover changed a pixel outside the link range") + } + + overlineY := min(fonts.Metrics.Baseline+1, fonts.Metrics.CellHeight-1) + x = hover.StartCol*fonts.Metrics.CellWidth + fonts.Metrics.CellWidth/2 + if overlineY != y && underlined.RGBAAt(x, overlineY) != plain.RGBAAt(x, overlineY) { + t.Error("hover drew a line above the text") + } +} + // TestFrameRendersAtEitherDisplayDensity is the cross-monitor regression // test. A low-DPI panel has smaller cells and therefore *more* of them for // the same physical screen, which is exactly the case where the per-cell diff --git a/src/internal/render/gridpainter.go b/src/internal/render/gridpainter.go index 08bc42d..48ccfa0 100644 --- a/src/internal/render/gridpainter.go +++ b/src/internal/render/gridpainter.go @@ -12,19 +12,33 @@ import ( "github.com/kgfly/SimpleNvimEditor/internal/uistate" ) +// HoverLink identifies a visible URL cell range to decorate for the current +// frame. EndCol is exclusive. +type HoverLink struct { + Active bool + GridID int + Row int + StartCol int + EndCol int +} + // Frame draws one full frame of the editor: the base grid, every placed // split/float grid on top of it, and finally the cursor. -func Frame(gtx layout.Context, fonts Fonts, snap uistate.Snapshot) { +func Frame(gtx layout.Context, fonts Fonts, snap uistate.Snapshot, hovered ...HoverLink) { defFg, defBg := snap.Highlight.DefaultColors() size := gtx.Constraints.Max paint.FillShape(gtx.Ops, defBg, clip.Rect(image.Rect(0, 0, size.X, size.Y)).Op()) + var hover HoverLink + if len(hovered) > 0 { + hover = hovered[0] + } // One cache per frame, shared by every grid: splits and floats draw // the same characters as the base grid, so they hit the same entries. glyphs := newGlyphCache(gtx, fonts) if base, ok := snap.Grids[1]; ok { - drawGrid(gtx, fonts, glyphs, snap.Highlight, base, image.Pt(0, 0)) + drawGrid(gtx, fonts, glyphs, snap.Highlight, base, image.Pt(0, 0), hover) } for _, p := range snap.Windows { gv, ok := snap.Grids[p.GridID] @@ -32,7 +46,7 @@ func Frame(gtx layout.Context, fonts Fonts, snap uistate.Snapshot) { continue } origin := image.Pt(p.Col*fonts.Metrics.CellWidth, p.Row*fonts.Metrics.CellHeight) - drawGrid(gtx, fonts, glyphs, snap.Highlight, gv, origin) + drawGrid(gtx, fonts, glyphs, snap.Highlight, gv, origin, hover) } drawCursor(gtx, fonts, snap, defFg) @@ -41,7 +55,7 @@ func Frame(gtx layout.Context, fonts Fonts, snap uistate.Snapshot) { // drawGrid paints every row of gv, offset by origin pixels. Cells are // grouped into same-highlight runs for the background fill, but glyphs are // drawn one cell at a time so text stays locked to the grid. -func drawGrid(gtx layout.Context, fonts Fonts, glyphs *glyphCache, hv uistate.HighlightView, gv uistate.GridView, origin image.Point) { +func drawGrid(gtx layout.Context, fonts Fonts, glyphs *glyphCache, hv uistate.HighlightView, gv uistate.GridView, origin image.Point, hover HoverLink) { cw, ch := fonts.Metrics.CellWidth, fonts.Metrics.CellHeight for row, cells := range gv.Data { y := origin.Y + row*ch @@ -73,6 +87,29 @@ func drawGrid(gtx layout.Context, fonts Fonts, glyphs *glyphCache, hv uistate.Hi drawText(gtx, glyphs, x+i*cw, y, cell.Text, fg) } } + if hover.Active && hover.GridID == gv.ID && hover.Row == row { + drawHoverUnderline(gtx, fonts.Metrics, hv, cells, origin, hover) + } + } +} + +func drawHoverUnderline(gtx layout.Context, metrics Metrics, hv uistate.HighlightView, cells []uistate.Cell, origin image.Point, hover HoverLink) { + start := max(0, hover.StartCol) + end := min(len(cells), hover.EndCol) + if start >= end { + return + } + // Gio's Dimensions.Baseline is measured up from the bottom edge, not + // down from the top. Convert it to a top-relative baseline before + // placing the stroke one pixel into the descent area. + baseline := metrics.CellHeight - metrics.Baseline + y := origin.Y + hover.Row*metrics.CellHeight + min(baseline+1, metrics.CellHeight-1) + bottom := origin.Y + (hover.Row+1)*metrics.CellHeight + thickness := max(1, metrics.CellHeight/16) + for col := start; col < end; col++ { + fg, _ := hv.Resolve(cells[col].HlID) + x := origin.X + col*metrics.CellWidth + paint.FillShape(gtx.Ops, fg, clip.Rect(image.Rect(x, y, x+metrics.CellWidth, min(y+thickness, bottom))).Op()) } } diff --git a/src/test/unit/render_test.go b/src/test/unit/render_test.go index c8525d9..14c31e2 100644 --- a/src/test/unit/render_test.go +++ b/src/test/unit/render_test.go @@ -142,7 +142,7 @@ func testFonts(t *testing.T) render.Fonts { // practical to assert against directly (see IMPLEMENTATION_PLAN in the // project history for why pixel-level assertions are out of scope for unit // tests), so this is our regression guard against nil-pointer/index panics. -func runFrame(t *testing.T, snap uistate.Snapshot) { +func runFrame(t *testing.T, snap uistate.Snapshot, hovered ...render.HoverLink) { t.Helper() defer func() { if r := recover(); r != nil { @@ -151,7 +151,7 @@ func runFrame(t *testing.T, snap uistate.Snapshot) { }() var ops op.Ops gtx := newTestContext(&ops, image.Pt(400, 300)) - render.Frame(gtx, testFonts(t), snap) + render.Frame(gtx, testFonts(t), snap, hovered...) } func TestFrameEmptyState(t *testing.T) { @@ -168,6 +168,17 @@ func TestFrameSingleGridWithContent(t *testing.T) { runFrame(t, s.Snapshot()) } +func TestFrameHoveredLinkRange(t *testing.T) { + s := uistate.New() + s.Apply([][]interface{}{ + {"grid_resize", []interface{}{1, 30, 2}}, + {"grid_line", []interface{}{1, 0, 0, []interface{}{[]interface{}{"https://example.com"}}}}, + }) + snap := s.Snapshot() + runFrame(t, snap, render.HoverLink{Active: true, GridID: 1, Row: 0, StartCol: 0, EndCol: 19}) + runFrame(t, snap, render.HoverLink{Active: true, GridID: 1, Row: 0, StartCol: 4, EndCol: 4}) +} + func TestFrameMultigridWithFloat(t *testing.T) { s := uistate.New() s.Apply([][]interface{}{