commit dff2f5e

delthas  ·  2024-07-02 23:50:44 +0000 UTC
parent d47a847
Add link image previews

This is made so easy thanks to Vaxis!
8 files changed,  +208, -7
M app.go
M go.mod
M go.sum
M app.go
+78, -0
  1@@ -4,7 +4,12 @@ import (
  2 	"crypto/tls"
  3 	"errors"
  4 	"fmt"
  5+	"image"
  6+	_ "image/gif"
  7+	_ "image/jpeg"
  8+	_ "image/png"
  9 	"net"
 10+	"net/http"
 11 	"os"
 12 	"os/exec"
 13 	"strings"
 14@@ -17,6 +22,7 @@ import (
 15 	"golang.org/x/net/context"
 16 	"golang.org/x/net/proxy"
 17 
 18+	"git.sr.ht/~delthas/senpai/events"
 19 	"git.sr.ht/~delthas/senpai/irc"
 20 	"git.sr.ht/~delthas/senpai/ui"
 21 )
 22@@ -109,6 +115,9 @@ type App struct {
 23 	lastCloseTime   time.Time
 24 
 25 	lastConfirm string
 26+
 27+	imageLoading bool
 28+	imageOverlay bool
 29 }
 30 
 31 func NewApp(cfg Config) (app *App, err error) {
 32@@ -481,6 +490,13 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 33 		app.win.JumpBufferNetwork(ev.NetID, ev.Buffer)
 34 	case statusLine:
 35 		app.addStatusLine(ev.netID, ev.line)
 36+	case *events.EventClickLink:
 37+		app.handleLinkEvent(ev)
 38+	case *events.EventImageLoaded:
 39+		app.win.ShowImage(ev.Image)
 40+		if ev.Image == nil {
 41+			app.imageLoading = false
 42+		}
 43 	default:
 44 		// TODO: missing event types
 45 	}
 46@@ -490,6 +506,15 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 47 func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 48 	x, y := ev.Col, ev.Row
 49 	w, h := app.win.Size()
 50+
 51+	if app.imageOverlay && ev.Button == vaxis.MouseLeftButton {
 52+		if ev.EventType == vaxis.EventPress {
 53+			app.win.ShowImage(nil)
 54+			app.imageOverlay = false
 55+		}
 56+		return
 57+	}
 58+
 59 	if ev.EventType == vaxis.EventPress {
 60 		if ev.Button == vaxis.MouseWheelUp {
 61 			if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
 62@@ -527,6 +552,8 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 63 				app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
 64 			} else if x > w-app.win.MemberWidth() && y >= 2 {
 65 				app.win.ClickMember(y - 2 + app.win.MemberOffset())
 66+			} else {
 67+				app.win.Click(x, y)
 68 			}
 69 		}
 70 		if ev.Button == vaxis.MouseMiddleButton {
 71@@ -781,6 +808,57 @@ func (app *App) handleKeyEvent(ev vaxis.Key) {
 72 	}
 73 }
 74 
 75+func (app *App) fetchImage(link string) (image.Image, error) {
 76+	c := http.Client{
 77+		Timeout: 6 * time.Second,
 78+	}
 79+	res, err := c.Head(link)
 80+	if err != nil {
 81+		return nil, err
 82+	}
 83+	if res.StatusCode != http.StatusOK {
 84+		return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
 85+	}
 86+	contentType := res.Header.Get("Content-Type")
 87+	switch contentType {
 88+	case "image/gif", "image/jpeg", "image/png":
 89+	default:
 90+		return nil, fmt.Errorf("unexpected content type: %v", contentType)
 91+	}
 92+	res, err = c.Get(link)
 93+	if err != nil {
 94+		return nil, err
 95+	}
 96+	img, _, err := image.Decode(res.Body)
 97+	res.Body.Close()
 98+	if err != nil {
 99+		return nil, err
100+	}
101+	return img, nil
102+}
103+
104+func (app *App) handleLinkEvent(ev *events.EventClickLink) {
105+	app.imageLoading = true
106+	go func() {
107+		img, err := app.fetchImage(ev.Link)
108+		if err != nil {
109+			app.events <- event{
110+				src: "*",
111+				content: &events.EventImageLoaded{
112+					Image: nil,
113+				},
114+			}
115+		} else {
116+			app.events <- event{
117+				src: "*",
118+				content: &events.EventImageLoaded{
119+					Image: img,
120+				},
121+			}
122+		}
123+	}()
124+}
125+
126 // requestHistory is a wrapper around irc.Session.RequestHistory to only request
127 // history when needed.
128 func (app *App) requestHistory() {
+9, -0
 1@@ -76,6 +76,15 @@ In order to select text with a mouse, hold SHIFT while clicking and dragging
 2 the mouse. *Clicking and dragging without holding SHIFT will not work, as
 3 senpai eats these events for eg selecting channels.*
 4 
 5+# OPENING LINKS
 6+
 7+In order to open links, refer to your terminal manual. On most terminals,
 8+opening links is done by holding CTRL, or SHIFT, while clicking the link.
 9+On the *foot* terminal, links can be opened by pressing CTRL+SHIFT+O.
10+
11+Additionally, by simply clicking an image link with the mouse, a small preview
12+will be shown, which can be closed by clicking again anywhere in the interface.
13+
14 # KEYBOARD SHORTCUTS
15 
16 *CTRL-A*
+22, -0
 1@@ -0,0 +1,22 @@
 2+package events
 3+
 4+import "image"
 5+
 6+type EventClick struct {
 7+	NetID  string
 8+	Buffer string
 9+}
10+
11+type EventClickNick struct {
12+	EventClick
13+	Nick string
14+}
15+
16+type EventClickLink struct {
17+	EventClick
18+	Link string
19+}
20+
21+type EventImageLoaded struct {
22+	Image image.Image // nil if error
23+}
M go.mod
+2, -0
1@@ -22,3 +22,5 @@ require (
2 	golang.org/x/image v0.18.0 // indirect
3 	golang.org/x/sys v0.21.0 // indirect
4 )
5+
6+replace git.sr.ht/~rockorager/vaxis => git.sr.ht/~delthas/vaxis v0.8.6-0.20240702234839-1922cc2973f6
M go.sum
+2, -2
 1@@ -1,7 +1,7 @@
 2+git.sr.ht/~delthas/vaxis v0.8.6-0.20240702234839-1922cc2973f6 h1:we7TiRN0uoGHTgGUXbmBZXwgUkGBOm4CbeC8ip41uhQ=
 3+git.sr.ht/~delthas/vaxis v0.8.6-0.20240702234839-1922cc2973f6/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
 4 git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082 h1:9Udx5fm4vRtmgDIBjy2ef5QioHbzpw5oHabbhpAUyEw=
 5 git.sr.ht/~emersion/go-scfg v0.0.0-20240128091534-2ae16e782082/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
 6-git.sr.ht/~rockorager/vaxis v0.9.2 h1:OKPaCG3S4b6jWnwUPC0zX+Zw16hhLcSgC2mLw/nwsV0=
 7-git.sr.ht/~rockorager/vaxis v0.9.2/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
 8 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
 9 github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
10 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+20, -1
 1@@ -7,6 +7,8 @@ import (
 2 	"time"
 3 
 4 	"git.sr.ht/~rockorager/vaxis"
 5+
 6+	"git.sr.ht/~delthas/senpai/events"
 7 )
 8 
 9 const Overlay = "/overlay"
10@@ -853,7 +855,8 @@ func (bs *BufferList) DrawHorizontalBufferList(vx *Vaxis, x0, y0, width int, off
11 	}
12 }
13 
14-func (bs *BufferList) DrawTimeline(vx *Vaxis, x0, y0, nickColWidth int) {
15+func (bs *BufferList) DrawTimeline(ui *UI, x0, y0, nickColWidth int) {
16+	vx := ui.vx
17 	clearArea(vx, x0, y0, bs.tlInnerWidth+nickColWidth+9, bs.tlHeight+2)
18 
19 	b := bs.cur()
20@@ -966,6 +969,7 @@ func (bs *BufferList) DrawTimeline(vx *Vaxis, x0, y0, nickColWidth int) {
21 				continue
22 			}
23 
24+			xb := x
25 			if y >= y0 {
26 				dx, di := printCluster(vx, x, y, -1, l, style)
27 				x += dx
28@@ -977,6 +981,21 @@ func (bs *BufferList) DrawTimeline(vx *Vaxis, x0, y0, nickColWidth int) {
29 				lbi += len(c)
30 				l = l[len([]rune(c)):]
31 			}
32+
33+			if style.Hyperlink != "" {
34+				ui.clickEvents = append(ui.clickEvents, clickEvent{
35+					xb: xb,
36+					xe: x,
37+					y:  y,
38+					event: &events.EventClickLink{
39+						EventClick: events.EventClick{
40+							NetID:  b.netID,
41+							Buffer: b.title,
42+						},
43+						Link: style.Hyperlink,
44+					},
45+				})
46+			}
47 		}
48 	}
49 
+66, -4
  1@@ -2,11 +2,13 @@ package ui
  2 
  3 import (
  4 	"fmt"
  5+	"image"
  6 	"strings"
  7 	"sync/atomic"
  8 	"time"
  9 
 10 	"git.sr.ht/~rockorager/vaxis"
 11+	"git.sr.ht/~rockorager/vaxis/widgets/align"
 12 
 13 	"git.sr.ht/~delthas/senpai/irc"
 14 )
 15@@ -37,9 +39,16 @@ type Vaxis struct {
 16 	window vaxis.Window
 17 }
 18 
 19+type clickEvent struct {
 20+	xb    int
 21+	xe    int
 22+	y     int
 23+	event interface{}
 24+}
 25+
 26 type UI struct {
 27 	vx     *Vaxis
 28-	Events chan vaxis.Event
 29+	Events chan any
 30 	exit   atomic.Value // bool
 31 	config Config
 32 
 33@@ -59,11 +68,16 @@ type UI struct {
 34 
 35 	channelColClicked bool
 36 	memberColClicked  bool
 37+
 38+	clickEvents []clickEvent
 39+
 40+	image vaxis.Image
 41 }
 42 
 43 func New(config Config) (ui *UI, err error) {
 44 	ui = &UI{
 45-		config: config,
 46+		config:      config,
 47+		clickEvents: make([]clickEvent, 0, 128),
 48 	}
 49 	if config.ChanColEnabled {
 50 		ui.channelWidth = config.ChanColWidth
 51@@ -94,7 +108,7 @@ func New(config Config) (ui *UI, err error) {
 52 
 53 	ui.exit.Store(false)
 54 
 55-	ui.Events = make(chan vaxis.Event, 128)
 56+	ui.Events = make(chan any, 128)
 57 	go func() {
 58 		for !ui.ShouldExit() {
 59 			ev := ui.vx.PollEvent()
 60@@ -219,6 +233,15 @@ func (ui *UI) ClickMember(i int) {
 61 	ui.memberClicked = i
 62 }
 63 
 64+func (ui *UI) Click(x, y int) {
 65+	for _, ev := range ui.clickEvents {
 66+		if x >= ev.xb && x < ev.xe && y == ev.y {
 67+			ui.Events <- ev.event
 68+			break
 69+		}
 70+	}
 71+}
 72+
 73 func (ui *UI) ScrollUp() {
 74 	ui.bs.ScrollUp(ui.bs.tlHeight / 2)
 75 }
 76@@ -523,6 +546,9 @@ func (ui *UI) Resize() {
 77 		ui.bs.ResizeTimeline(innerWidth, h-2, textWidth)
 78 	}
 79 	ui.ScrollToBuffer()
 80+	if ui.image != nil {
 81+		ui.image.Resize(w, h)
 82+	}
 83 	ui.vx.Refresh()
 84 }
 85 
 86@@ -538,10 +564,41 @@ func (ui *UI) Notify(title string, body string) {
 87 	ui.vx.Notify(title, body)
 88 }
 89 
 90+func (ui *UI) ImageReady() bool {
 91+	if ui.image == nil {
 92+		return false
 93+	}
 94+	iw, ih := ui.image.CellSize()
 95+	return iw != 0 || ih != 0
 96+}
 97+
 98+func (ui *UI) ShowImage(img image.Image) bool {
 99+	if img == nil {
100+		if ui.image != nil {
101+			ui.image.Destroy()
102+			ui.image = nil
103+		}
104+		return true
105+	}
106+
107+	vi, err := ui.vx.NewImage(img)
108+	if err != nil {
109+		return false
110+	}
111+	w, h := ui.vx.window.Size()
112+	w = w * 9 / 10
113+	h = h * 9 / 10
114+	vi.Resize(w, h)
115+	ui.image = vi
116+	return true
117+}
118+
119 func (ui *UI) Draw(members []irc.Member) {
120+	ui.clickEvents = ui.clickEvents[:0]
121+
122 	w, h := ui.vx.window.Size()
123 
124-	ui.bs.DrawTimeline(ui.vx, ui.channelWidth, 0, ui.config.NickColWidth)
125+	ui.bs.DrawTimeline(ui, ui.channelWidth, 0, ui.config.NickColWidth)
126 	if ui.channelWidth == 0 {
127 		ui.bs.DrawHorizontalBufferList(ui.vx, 0, h-1, w-ui.memberWidth, &ui.channelOffset)
128 	} else {
129@@ -584,6 +641,11 @@ func (ui *UI) Draw(members []irc.Member) {
130 		ui.e.Draw(ui.vx, 9+ui.channelWidth+ui.config.NickColWidth, h-1, hint)
131 	}
132 
133+	if ui.image != nil {
134+		iw, ih := ui.image.CellSize()
135+		ui.image.Draw(align.Center(ui.vx.window, iw, ih))
136+	}
137+
138 	ui.vx.Render()
139 }
140 
+9, -0
 1@@ -45,6 +45,15 @@ func (app *App) addStatusLine(netID string, line ui.Line) {
 2 }
 3 
 4 func (app *App) setStatus() {
 5+	if app.imageLoading && app.win.ImageReady() {
 6+		app.imageLoading = false
 7+		app.imageOverlay = true
 8+	}
 9+	if app.imageLoading {
10+		app.win.SetStatus("Loading image...")
11+		return
12+	}
13+
14 	netID, buffer := app.win.CurrentBuffer()
15 	s := app.sessions[netID]
16 	if s == nil {