From 68525224d5e18b94d86a72189fcee5fccc0749e1 Mon Sep 17 00:00:00 2001 From: Lobo Date: Sat, 11 Oct 2025 20:20:36 -0300 Subject: [PATCH] first working commit --- .gitignore | 2 + README.md | 14 ++++ command.go | 74 ++++++++++++++++++ display.go | 96 +++++++++++++++++++++++ event.go | 32 ++++++++ go.mod | 18 +++++ go.sum | 59 ++++++++++++++ main.go | 167 ++++++++++++++++++++++++++++++++++++++++ widgets/pager/pager.go | 171 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 633 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 command.go create mode 100644 display.go create mode 100644 event.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 widgets/pager/pager.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe949d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/chat.log +/nanite diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e4d866 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# nanite + +it ony a [nanochat] client! +wrote this while simultaneously learning how to use [vaxis] and it sure was an experience + +## use it + +``` +$ go build . +$ ./nanite very.real-server.com +``` + +[nanochat]: https://git.phial.org/d6/nanochat +[vaxis]: https://git.sr.ht/~rockorager/vaxis diff --git a/command.go b/command.go new file mode 100644 index 0000000..1fb5786 --- /dev/null +++ b/command.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "strconv" +) + +func (app *App) Send(data string) (num int, err error) { + if _, err := fmt.Fprintf(app.conn, "SEND %s\n", data); err != nil { + return 0, err + } + + if !app.scanner.Scan() { + return 0, app.scanner.Err() + } + numRaw := app.scanner.Text() + num, err = strconv.Atoi(numRaw) + if err != nil { + return 0, err + } + return num, nil +} + +func (app *App) Poll(since int) (num int, err error) { + if _, err := fmt.Fprintf(app.conn, "POLL %d\n", since); err != nil { + return 0, err + } + + if !app.scanner.Scan() { + return 0, app.scanner.Err() + } + numRaw := app.scanner.Text() + num, err = strconv.Atoi(numRaw) + if err != nil { + return 0, err + } + return num, nil +} + +func (app *App) Last(n int) (err error) { + if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil { + return err + } + + var nsrv int + if !app.scanner.Scan() { + return app.scanner.Err() + } + nsrvRaw := app.scanner.Text() + nsrv, err = strconv.Atoi(nsrvRaw) + if err != nil { + return err + } + + for range nsrv { + if !app.scanner.Scan() { + return app.scanner.Err() + } + app.incoming <- Message(app.scanner.Text()) + } + + var last int + if !app.scanner.Scan() { + return app.scanner.Err() + } + lastRaw := app.scanner.Text() + last, err = strconv.Atoi(lastRaw) + if err != nil { + return err + } + app.incoming <- SetLast(last) + + return nil +} diff --git a/display.go b/display.go new file mode 100644 index 0000000..2bf45e8 --- /dev/null +++ b/display.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "math" + + "codeberg.org/lobo/nanite/widgets/pager" + "git.sr.ht/~rockorager/vaxis" + "git.sr.ht/~rockorager/vaxis/widgets/textinput" +) + +func (app *App) InitUI() error { + var err error + app.vx, err = vaxis.New(vaxis.Options{}) + if err != nil { + return err + } + app.input = textinput.New() + app.pager = &pager.Model{ + WrapMode: pager.WrapWords, + } + app.resize() + return nil +} + +func (app *App) FinishUI() { + app.vx.Close() +} + +func (app *App) resize() { + app.dirty = true + + win := app.vx.Window() + app.w.log = win.New(0, 1, win.Width, win.Height-3) + app.w.title = win.New(0, 0, win.Width, 1) + app.w.status = win.New(0, win.Height-2, win.Width, 1) + app.w.input = win.New(0, win.Height-1, win.Width, 1) + app.pager.Offset = math.MaxInt +} + +func (app *App) Redraw() { + if !app.dirty { + return + } + app.dirty = false + + // set window title and draw titlebar + app.w.title.Clear() + titlebarStyle := vaxis.Style{Attribute: vaxis.AttrBold} + + if app.conn != nil { + app.vx.SetTitle(fmt.Sprintf("nanite (%s:%s)", app.host, app.port)) + app.w.title.PrintTruncate(0, + vaxis.Segment{ + Text: "• ", + Style: titlebarStyle, + }, + vaxis.Segment{ + Text: app.host, + Style: titlebarStyle, + }, + vaxis.Segment{ + Text: ":", + Style: titlebarStyle, + }, + vaxis.Segment{ + Text: app.port, + Style: titlebarStyle, + }, + ) + } else { + app.vx.SetTitle("nanite (disconnected)") + app.w.title.PrintTruncate(0, + vaxis.Segment{ + Text: "✖ (disconnected)", + Style: titlebarStyle, + }, + ) + } + + // draw statusbar + statusStyle := vaxis.Style{Attribute: vaxis.AttrReverse} + app.w.status.Fill(vaxis.Cell{Style: statusStyle}) + app.w.status.PrintTruncate(0, + vaxis.Segment{ + Text: app.nick, + Style: statusStyle, + }, + ) + + // let the widgets draw themselves + app.pager.Layout() + app.pager.Draw(app.w.log) + app.input.Draw(app.w.input) + app.vx.Render() +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..d41eb55 --- /dev/null +++ b/event.go @@ -0,0 +1,32 @@ +package main + +type IncomingEvent interface { + HandleIncoming(*App) +} +type OutgoingEvent interface { + HandleOutgoing(*App) +} + +type Message string + +func (m Message) HandleIncoming(app *App) { + app.AppendMessage(string(m)) +} +func (m Message) HandleOutgoing(app *App) { + num, _ := app.Send(string(m)) + app.incoming <- SetLast(num) + +} + +type Poll int + +func (p Poll) HandleOutgoing(app *App) { + num, _ := app.Poll(int(p)) + go app.Last(num) +} + +type SetLast int + +func (m SetLast) HandleIncoming(app *App) { + app.last = int(m) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..884d00d --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module codeberg.org/lobo/nanite + +go 1.24.4 + +require ( + git.sr.ht/~rockorager/vaxis v0.15.0 + github.com/rivo/uniseg v0.4.7 +) + +require ( + github.com/containerd/console v1.0.3 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sixel v0.0.5 // indirect + github.com/soniakeys/quant v1.0.0 // indirect + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect + golang.org/x/image v0.9.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a30534 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +git.sr.ht/~rockorager/vaxis v0.15.0 h1:r9VUcDfKRNe9Rp988konQ1Gy8vbtpQajGSYXNAYx8hI= +git.sr.ht/~rockorager/vaxis v0.15.0/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= +github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g= +golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..01b965f --- /dev/null +++ b/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "math" + "net" + "os" + "sync" + "time" + + "codeberg.org/lobo/nanite/widgets/pager" + "git.sr.ht/~rockorager/vaxis" + "git.sr.ht/~rockorager/vaxis/widgets/textinput" +) + +var ( + ErrAlreadyConnected = errors.New("already connected") +) + +type App struct { + ctx context.Context + stop context.CancelFunc + mu sync.RWMutex + + conn net.Conn + scanner *bufio.Scanner + host, port string + + nick string + last int + ticker *time.Ticker + + incoming chan IncomingEvent + outgoing chan OutgoingEvent + + vx *vaxis.Vaxis + w struct { + log, title, status, input vaxis.Window + } + pager *pager.Model + input *textinput.Model + dirty bool +} + +func (app *App) Connect(host, port string) (err error) { + if app.conn != nil { + return ErrAlreadyConnected + } + app.host = host + app.port = port + app.conn, err = net.Dial("tcp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + app.scanner = bufio.NewScanner(app.conn) + app.incoming = make(chan IncomingEvent) + app.outgoing = make(chan OutgoingEvent) + app.ticker = time.NewTicker(2 * time.Second) + + go func() { + for { + select { + case ev := <-app.outgoing: + ev.HandleOutgoing(app) + case <-app.ctx.Done(): + return + } + } + }() + + return nil +} + +func (app *App) Disconnect() { + if app.conn != nil { + app.conn.Write([]byte("QUIT\n")) + app.conn.Close() + app.conn = nil + } +} + +func (app *App) AppendMessage(data string) { + app.pager.Segments = append(app.pager.Segments, + vaxis.Segment{Text: data}, + vaxis.Segment{Text: "\n"}, + ) + app.last += 1 + app.pager.Offset = math.MaxInt + app.dirty = true +} + +func (app *App) HandleTerminalEvent(ev vaxis.Event) { + app.dirty = true + + switch ev := ev.(type) { + case vaxis.Resize: + app.resize() + case vaxis.Key: + if ev.MatchString("ctrl+c") { + app.stop() + } + switch { + case ev.MatchString("up"): + app.pager.ScrollUp() + case ev.MatchString("down"): + app.pager.ScrollDown() + case ev.MatchString("ctrl+l"): + app.Redraw() + app.vx.Refresh() + app.dirty = false + case ev.MatchString("enter"): + if len(app.input.Characters()) != 0 { + message := fmt.Sprintf("%s: %s", app.nick, app.input.String()) + app.AppendMessage(message) + app.outgoing <- Message(message) + app.input.SetContent("") + } + } + } + + app.input.Update(ev) +} + +func main() { + args := os.Args + if len(args) < 2 || len(args) > 3 { + fmt.Printf("usage: %s host [port]\n", args[0]) + os.Exit(1) + } + + port := "44322" + if len(args) == 3 { + port = args[2] + } + + app := App{} + app.ctx, app.stop = context.WithCancel(context.Background()) + defer app.stop() + + if err := app.Connect(args[1], port); err != nil { + panic(err) + } + defer app.Disconnect() + + app.InitUI() + defer app.FinishUI() + + app.nick = "wolfdog" + go app.Last(20) + + for { + select { + case ev := <-app.vx.Events(): + app.HandleTerminalEvent(ev) + case ev := <-app.incoming: + ev.HandleIncoming(&app) + case <-app.ticker.C: + app.outgoing <- Poll(app.last) + case <-app.ctx.Done(): + return + } + app.Redraw() + } +} diff --git a/widgets/pager/pager.go b/widgets/pager/pager.go new file mode 100644 index 0000000..439acbb --- /dev/null +++ b/widgets/pager/pager.go @@ -0,0 +1,171 @@ +// This is a "fork" of Vaxis' pager widget that adds Unicode line breaking. +// See . + +package pager + +import ( + "strings" + + "git.sr.ht/~rockorager/vaxis" + "github.com/rivo/uniseg" +) + +const ( + WrapFast = iota + WrapWords +) + +var defaultFill = vaxis.Character{Grapheme: " "} + +type Model struct { + Segments []vaxis.Segment + lines []*line + Fill vaxis.Cell + Offset int + WrapMode int + width int +} + +type line struct { + characters []vaxis.Cell +} + +func (l *line) append(t vaxis.Cell) { + l.characters = append(l.characters, t) +} + +func (m *Model) Draw(win vaxis.Window) { + w, h := win.Size() + if w != m.width { + m.width = w + m.Layout() + } + if len(m.lines)-m.Offset < h { + m.Offset = len(m.lines) - h + } + if m.Offset < 0 { + m.Offset = 0 + } + if m.Fill.Grapheme == "" { + m.Fill.Character = defaultFill + } + win.Fill(m.Fill) + for row, l := range m.lines { + if row < m.Offset { + continue + } + if (row - m.Offset) >= h { + return + } + col := 0 + for _, cell := range l.characters { + win.SetCell(col, row-m.Offset, cell) + col += cell.Width + } + } +} + +func (m *Model) layoutFast() { + l := &line{} + col := 0 + for _, seg := range m.Segments { + for _, char := range vaxis.Characters(seg.Text) { + if strings.ContainsRune(char.Grapheme, '\n') { + m.lines = append(m.lines, l) + l = &line{} + col = 0 + continue + } + cell := vaxis.Cell{ + Character: char, + Style: seg.Style, + } + l.append(cell) + col += char.Width + if col >= m.width { + m.lines = append(m.lines, l) + l = &line{} + col = 0 + } + } + } +} + +func (m *Model) layoutSlow() { + cols := m.width + col := 0 + l := &line{} + + var ( + state = -1 + segment string + ) + + for _, seg := range m.Segments { + rest := seg.Text + for len(rest) > 0 { + segment, rest, _, state = uniseg.FirstLineSegmentInString(rest, state) + chars := vaxis.Characters(segment) + total := 0 + for _, char := range chars { + total += char.Width + } + + switch { + case total > cols: + case total+col > cols: + m.lines = append(m.lines, l) + l = &line{} + col = 0 + default: + } + + for _, char := range chars { + if uniseg.HasTrailingLineBreakInString(char.Grapheme) { + m.lines = append(m.lines, l) + l = &line{} + col = 0 + continue + } + cell := vaxis.Cell{ + Character: char, + Style: seg.Style, + } + l.append(cell) + col += char.Width + if col >= cols { + m.lines = append(m.lines, l) + l = &line{} + col = 0 + } + } + } + } +} + +func (m *Model) Layout() { + m.lines = []*line{} + switch m.WrapMode { + case WrapFast: + m.layoutFast() + case WrapWords: + m.layoutSlow() + } +} + +// Scrolls the pager down n lines, if it can +func (m *Model) ScrollDown() { + m.Offset += 1 +} + +func (m *Model) ScrollUp() { + m.Offset -= 1 +} + +func (m *Model) ScrollDownN(n int) { + m.Offset += n +} + +func (m *Model) ScrollUpN(n int) { + m.Offset -= n +}