Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 30 additions & 4 deletions src/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
176 changes: 176 additions & 0 deletions src/internal/app/link.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading