add hinting

This commit is contained in:
Liam Galvin 2018-10-29 20:35:49 +00:00
parent 2907bb69eb
commit 1c96cb83d5
14 changed files with 499 additions and 78 deletions

4
Gopkg.lock generated
View File

@ -88,12 +88,12 @@
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/image" name = "golang.org/x/image"
packages = ["font","math/fixed"] packages = ["bmp","font","math/fixed"]
revision = "991ec62608f3c0da01d400756917825d1e2fd528" revision = "991ec62608f3c0da01d400756917825d1e2fd528"
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "62428a3f67dc7a8a89ecb5666b6d8a6641788f89c61d0b0b4dd99982923bb0c6" inputs-digest = "6f1eff05a1c5ca969d1f54eb8002e3213fda92b99a459e6729098b2497de5b8c"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -10,7 +10,11 @@ The project is experimental at the moment, so you probably won't want to rely on
Ensure you have your latest graphics card drivers installed before use. Ensure you have your latest graphics card drivers installed before use.
Sixels are now supported. ## Contextual Hints
![Example hint](hint.png)
## Sixel Support
![Example sixel](sixel.png) ![Example sixel](sixel.png)
@ -55,12 +59,17 @@ Sixels are now supported.
## Keyboard Shortcuts ## Keyboard Shortcuts
| Operation | Key(s) | | Operation | Key(s) |
| ------------------ | ---------------- | | --------------------- | -------------------- |
| Copy | ctrl + shift + c | | Select text | click + drag |
| Paste | ctrl + shift + v | | Select word | double click |
| Toggle slomo | ctrl + shift + ; | | Select line | triple click |
| Interrupt (SIGINT) | ctrl + c | | Copy | ctrl + shift + c |
| Paste | ctrl + shift + v |
| Google selected text | ctrl + shift + g |
| Report bug in aminal | ctrl + shift + r |
| Explain text | ctrl + shift + click |
| Toggle slomo | ctrl + shift + ; |
## Configuration ## Configuration

49
buffer/hint.go Normal file
View File

@ -0,0 +1,49 @@
package buffer
import (
"fmt"
"strings"
"github.com/liamg/aminal/hints"
)
func (buffer *Buffer) GetHintAtPosition(col uint16, row uint16) *hints.Hint {
cell := buffer.GetCell(col, row)
if cell == nil || cell.Rune() == 0x00 {
return nil
}
candidate := ""
for i := col; i >= 0; i-- {
cell := buffer.GetCell(i, row)
if cell == nil {
break
}
if isRuneWordSelectionMarker(cell.Rune()) {
break
}
candidate = fmt.Sprintf("%c%s", cell.Rune(), candidate)
}
trimmed := strings.TrimLeft(candidate, " ")
sx := col - uint16(len(trimmed)-1)
for i := col + 1; i < buffer.viewWidth; i++ {
cell := buffer.GetCell(i, row)
if cell == nil {
break
}
if isRuneWordSelectionMarker(cell.Rune()) {
break
}
candidate = fmt.Sprintf("%s%c", candidate, cell.Rune())
}
line := buffer.lines[buffer.convertViewLineToRawLine(row)]
return hints.Get(strings.Trim(candidate, " "), line.String(), sx, row)
}

46
gui/explain.go Normal file
View File

@ -0,0 +1,46 @@
package gui
import (
"github.com/go-gl/gl/all-core/gl"
"github.com/liamg/aminal/hints"
)
type annotation struct {
hint *hints.Hint
}
func newAnnotation(it *hints.Hint) *annotation {
return &annotation{
hint: it,
}
}
func (a *annotation) render(gui *GUI) {
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
lines := gui.terminal.GetVisibleLines()
for y := 0; y < len(lines); y++ {
cells := lines[y].Cells()
for x := 0; x < len(cells); x++ {
if int(x) >= len(cells) {
break
}
cell := cells[x]
var colour *[3]float32
var alpha float32 = 0.6
if y == int(a.hint.StartY) {
if x >= int(a.hint.StartX) && x <= int(a.hint.StartX+uint16(len(a.hint.Word))) {
colour = &[3]float32{0.2, 1.0, 0.2}
alpha = 1.0
}
}
gui.renderer.DrawCellText(cell, uint(x), uint(y), alpha, colour)
}
}
gui.textbox(a.hint.StartX+1, a.hint.StartY+3, a.hint.Description)
}

View File

@ -3,6 +3,7 @@ package gui
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os/exec"
"runtime" "runtime"
"time" "time"
@ -18,29 +19,32 @@ import (
) )
type GUI struct { type GUI struct {
window *glfw.Window window *glfw.Window
logger *zap.SugaredLogger logger *zap.SugaredLogger
config *config.Config config *config.Config
terminal *terminal.Terminal terminal *terminal.Terminal
width int //window width in pixels width int //window width in pixels
height int //window height in pixels height int //window height in pixels
font *glfont.Font font *glfont.Font
boldFont *glfont.Font boldFont *glfont.Font
fontScale float32 fontScale float32
renderer *OpenGLRenderer renderer *OpenGLRenderer
colourAttr uint32 colourAttr uint32
mouseDown bool mouseDown bool
overlay overlay
terminalAlpha float32
} }
func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI { func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI {
return &GUI{ return &GUI{
config: config, config: config,
logger: logger, logger: logger,
width: 800, width: 800,
height: 600, height: 600,
terminal: terminal, terminal: terminal,
fontScale: 14.0, fontScale: 14.0,
terminalAlpha: 1,
} }
} }
@ -87,14 +91,6 @@ func (gui *GUI) resize(w *glfw.Window, width int, height int) {
} }
func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) {
if yoff > 0 {
gui.terminal.ScrollUp(1)
} else {
gui.terminal.ScrollDown(1)
}
}
func (gui *GUI) getTermSize() (uint, uint) { func (gui *GUI) getTermSize() (uint, uint) {
if gui.renderer == nil { if gui.renderer == nil {
return 0, 0 return 0, 0
@ -242,9 +238,9 @@ func (gui *GUI) Render() error {
colour = &gui.config.ColourScheme.Selection colour = &gui.config.ColourScheme.Selection
} }
gui.renderer.DrawCellBg(cell, uint(x), uint(y), cursor, colour) gui.renderer.DrawCellBg(cell, uint(x), uint(y), cursor, colour, false)
if hasText { if hasText {
gui.renderer.DrawCellText(cell, uint(x), uint(y), nil) gui.renderer.DrawCellText(cell, uint(x), uint(y), 1.0, nil)
} }
gui.renderer.DrawCellImage(cell, uint(x), uint(y)) gui.renderer.DrawCellImage(cell, uint(x), uint(y))
@ -252,6 +248,8 @@ func (gui *GUI) Render() error {
} }
} }
gui.renderOverlay()
gui.window.SwapBuffers() gui.window.SwapBuffers()
} }
@ -340,3 +338,19 @@ func (gui *GUI) createProgram() (uint32, error) {
return prog, nil return prog, nil
} }
func (gui *GUI) launchTarget(target string) {
cmd := "xdg-open"
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "windows":
cmd = "start"
}
if err := exec.Command(cmd, target).Run(); err != nil {
gui.logger.Errorf("Failed to launch external command %s: %s", cmd, err)
}
}

View File

@ -2,6 +2,7 @@ package gui
import ( import (
"fmt" "fmt"
"net/url"
"github.com/go-gl/glfw/v3.2/glfw" "github.com/go-gl/glfw/v3.2/glfw"
) )
@ -22,8 +23,15 @@ func modsPressed(pressed glfw.ModifierKey, mods ...glfw.ModifierKey) bool {
} }
func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) {
if action == glfw.Repeat || action == glfw.Press { if action == glfw.Repeat || action == glfw.Press {
if gui.overlay != nil {
if key == glfw.KeyEscape {
gui.setOverlay(nil)
}
}
gui.logger.Debugf("KEY PRESS: key=0x%X scan=0x%X", key, scancode) gui.logger.Debugf("KEY PRESS: key=0x%X scan=0x%X", key, scancode)
modStr := "" modStr := ""
@ -39,6 +47,13 @@ func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Acti
case glfw.KeyC: case glfw.KeyC:
gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText()) gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText())
return return
case glfw.KeyG:
keywords := gui.terminal.ActiveBuffer().GetSelectedText()
if keywords != "" {
gui.launchTarget(fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(keywords)))
}
case glfw.KeyR:
gui.launchTarget("https://github.com/liamg/aminal/issues/new/choose")
case glfw.KeyV: case glfw.KeyV:
if s, err := gui.window.GetClipboardString(); err == nil { if s, err := gui.window.GetClipboardString(); err == nil {
_ = gui.terminal.Paste([]byte(s)) _ = gui.terminal.Paste([]byte(s))

View File

@ -3,20 +3,39 @@ package gui
import ( import (
"fmt" "fmt"
"math" "math"
"os/exec"
"runtime"
"github.com/go-gl/glfw/v3.2/glfw" "github.com/go-gl/glfw/v3.2/glfw"
"github.com/liamg/aminal/terminal" "github.com/liamg/aminal/terminal"
) )
func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) {
if yoff > 0 {
gui.terminal.ScrollUp(1)
} else {
gui.terminal.ScrollDown(1)
}
}
func (gui *GUI) mouseMoveCallback(w *glfw.Window, xpos float64, ypos float64) { func (gui *GUI) mouseMoveCallback(w *glfw.Window, xpos float64, ypos float64) {
px, py := w.GetCursorPos() px, py := w.GetCursorPos()
x := uint16(math.Floor((px - float64(gui.renderer.areaX)) / float64(gui.renderer.CellWidth()))) x := uint16(math.Floor((px - float64(gui.renderer.areaX)) / float64(gui.renderer.CellWidth())))
y := uint16(math.Floor((py - float64(gui.renderer.areaY)) / float64(gui.renderer.CellHeight()))) y := uint16(math.Floor((py - float64(gui.renderer.areaY)) / float64(gui.renderer.CellHeight())))
if gui.mouseDown { if gui.mouseDown {
gui.terminal.ActiveBuffer().EndSelection(x, y, false) gui.terminal.ActiveBuffer().EndSelection(x, y, false)
} else {
if gui.terminal.UsingMainBuffer() {
hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y)
if hint != nil {
gui.setOverlay(newAnnotation(hint))
} else {
gui.setOverlay(nil)
}
}
} }
if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" {
@ -26,24 +45,15 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, xpos float64, ypos float64) {
} }
} }
func (gui *GUI) launchTarget(target string) {
cmd := "xdg-open"
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "windows":
cmd = "start"
}
if err := exec.Command(cmd, target).Run(); err != nil {
gui.logger.Errorf("Failed to launch external command %s: %s", cmd, err)
}
}
func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) {
if gui.overlay != nil {
if button == glfw.MouseButtonRight && action == glfw.Release {
gui.setOverlay(nil)
}
return
}
// before we forward clicks on (below), we need to handle them locally for url clicking, text highlighting etc. // before we forward clicks on (below), we need to handle them locally for url clicking, text highlighting etc.
px, py := w.GetCursorPos() px, py := w.GetCursorPos()
x := uint16(math.Floor((px - float64(gui.renderer.areaX)) / float64(gui.renderer.CellWidth()))) x := uint16(math.Floor((px - float64(gui.renderer.areaX)) / float64(gui.renderer.CellWidth())))
@ -51,14 +61,17 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act
tx := int(x) + 1 // vt100 is 1 indexed tx := int(x) + 1 // vt100 is 1 indexed
ty := int(y) + 1 ty := int(y) + 1
if action == glfw.Press { if button == glfw.MouseButtonLeft {
gui.mouseDown = true
gui.terminal.ActiveBuffer().StartSelection(x, y) if action == glfw.Press {
} else if action == glfw.Release { gui.mouseDown = true
gui.mouseDown = false gui.terminal.ActiveBuffer().StartSelection(x, y)
gui.terminal.ActiveBuffer().EndSelection(x, y, true) } else if action == glfw.Release {
if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { gui.mouseDown = false
go gui.launchTarget(url) gui.terminal.ActiveBuffer().EndSelection(x, y, true)
if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" {
go gui.launchTarget(url)
}
} }
} }
// https://www.xfree86.org/4.8.0/ctlseqs.html // https://www.xfree86.org/4.8.0/ctlseqs.html

18
gui/overlays.go Normal file
View File

@ -0,0 +1,18 @@
package gui
type overlay interface {
render(gui *GUI)
}
func (gui *GUI) setOverlay(m overlay) {
defer gui.terminal.SetDirty()
gui.overlay = m
}
func (gui *GUI) renderOverlay() {
if gui.overlay == nil || !gui.terminal.UsingMainBuffer() {
return
}
gui.overlay.render(gui)
}

View File

@ -195,7 +195,7 @@ func (r *OpenGLRenderer) DrawCursor(col uint, row uint, colour config.Colour) {
rect.Draw() rect.Draw()
} }
func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor bool, colour *config.Colour) { func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor bool, colour *config.Colour, force bool) {
var bg [3]float32 var bg [3]float32
@ -212,7 +212,7 @@ func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor
} }
} }
if bg != r.config.ColourScheme.Background { if bg != r.config.ColourScheme.Background || force {
rect := r.getRectangle(col, row) rect := r.getRectangle(col, row)
rect.setColour(bg) rect.setColour(bg)
rect.Draw() rect.Draw()
@ -220,23 +220,20 @@ func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor
} }
func (r *OpenGLRenderer) DrawCellText(cell buffer.Cell, col uint, row uint, colour *config.Colour) { func (r *OpenGLRenderer) DrawCellText(cell buffer.Cell, col uint, row uint, alpha float32, colour *[3]float32) {
var fg [3]float32 var fg [3]float32
if colour != nil { if colour != nil {
fg = *colour fg = *colour
} else if cell.Attr().Reverse {
fg = cell.Bg()
} else { } else {
if cell.Attr().Reverse { fg = cell.Fg()
fg = cell.Bg()
} else {
fg = cell.Fg()
}
} }
var alpha float32 = 1
if cell.Attr().Dim { if cell.Attr().Dim {
alpha = 0.5 alpha = 0.5 * alpha
} }
r.font.SetColor(fg[0], fg[1], fg[2], alpha) r.font.SetColor(fg[0], fg[1], fg[2], alpha)

97
gui/textbox.go Normal file
View File

@ -0,0 +1,97 @@
package gui
import (
"fmt"
"github.com/liamg/aminal/buffer"
)
func (gui *GUI) textbox(col uint16, row uint16, text string) {
lines := []string{}
line := ""
word := ""
maxWidth := int(gui.terminal.ActiveBuffer().ViewWidth()) - 4
maxHeight := (int(gui.terminal.ActiveBuffer().ViewHeight()) / 2) - 2
if maxHeight < 1 {
return
}
longestLine := 0
addWord := func() {
if len(line)+len(word) <= maxWidth {
line = fmt.Sprintf("%s%s", line, word)
if len(line) < maxWidth {
line = fmt.Sprintf("%s ", line)
} else {
lines = append(lines, line)
line = ""
}
} else {
lines = append(lines, line)
line = word
for len(line) > maxWidth {
// break word into bits
}
}
word = ""
}
addLine := func() bool {
addWord()
if len(line) > longestLine {
longestLine = len(line)
}
lines = append(lines, line)
if len(lines) >= maxHeight-1 {
lines = append(lines, "...")
return true
}
line = ""
return false
}
var done = false
DONE:
for _, c := range text {
switch c {
case 0x0d:
continue
case 0x0a:
if done = addLine(); done {
break DONE
}
case ' ':
addWord()
default:
word = fmt.Sprintf("%s%c", word, c)
}
}
if word != "" {
addWord()
}
if line != "" && !done {
addLine()
}
for hx := col; hx < col+uint16(longestLine)+2; hx++ {
for hy := row - 1; hy < row+uint16(len(lines))+1; hy++ {
gui.renderer.DrawCellBg(buffer.NewBackgroundCell([3]float32{0, 0, 0}), uint(hx), uint(hy), false, nil, true)
}
}
x := (float32(col) * gui.renderer.cellWidth)
gui.font.SetColor(0.2, 1, 0.2, 1)
for i, line := range lines {
y := (float32(row+1+uint16(i)) * gui.renderer.cellHeight) - (gui.font.LinePadding())
gui.font.Print(x, y, fmt.Sprintf(" %s", line))
}
}

BIN
hint.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

22
hints/hint.go Normal file
View File

@ -0,0 +1,22 @@
package hints
type Hint struct {
Word string
StartX uint16
StartY uint16
Line string
Description string
}
type hinter func(word string, context string, wordX uint16, wordY uint16) *Hint
var hinters = []hinter{}
func Get(word string, context string, wordX uint16, wordY uint16) *Hint {
for _, exp := range hinters {
if h := exp(word, context, wordX, wordY); h != nil {
return h
}
}
return nil
}

122
hints/perms.go Normal file
View File

@ -0,0 +1,122 @@
package hints
import (
"fmt"
"regexp"
"strings"
)
type perms struct {
IsDirectory bool
Owner access
Group access
World access
}
type access struct {
Read bool
Write bool
Execute bool
}
func (p perms) Numeric() string {
return p.Owner.Numeric() + p.Group.Numeric() + p.World.Numeric()
}
func (a access) Nice() string {
all := []string{}
if a.Read {
all = append(all, "read")
}
if a.Write {
all = append(all, "write")
}
if a.Execute {
all = append(all, "execute")
}
return strings.Join(all, ", ")
}
func (a access) Numeric() string {
var n uint8
if a.Read {
n += 4
}
if a.Write {
n += 2
}
if a.Execute {
n++
}
return fmt.Sprintf("%d", n)
}
func parsePermissionString(s string) (perms, error) {
if !isPermString(s) {
return perms{}, fmt.Errorf("Invalid permission string")
}
p := perms{}
p.IsDirectory = s[0] == 'd'
p.Owner.Read = s[1] == 'r'
p.Owner.Write = s[2] == 'w'
p.Owner.Execute = s[3] == 'x'
p.Group.Read = s[4] == 'r'
p.Group.Write = s[5] == 'w'
p.Group.Execute = s[6] == 'x'
p.World.Read = s[7] == 'r'
p.World.Write = s[8] == 'w'
p.World.Execute = s[9] == 'x'
return p, nil
}
func init() {
hinters = append(hinters, hintPerms)
}
func hintPerms(word string, context string, wordX uint16, wordY uint16) *Hint {
item := &Hint{
Line: context,
Word: word,
StartX: wordX,
StartY: wordY,
}
if wordX == 0 {
p, err := parsePermissionString(word)
if err != nil {
return nil
}
typ := "file"
if p.IsDirectory {
typ = "directory"
}
item.Description = fmt.Sprintf(`Permissions:
Type: %s
Numeric: %s
Owner: %s
Group: %s
World: %s`,
typ,
p.Numeric(),
p.Owner.Nice(),
p.Group.Nice(),
p.World.Nice(),
)
return item
}
return nil
}
func isPermString(s string) bool {
re := regexp.MustCompile("[dl\\-sS]{1}[sSrwx\\-]{9}")
return re.MatchString(s)
}

View File

@ -16,8 +16,9 @@ import (
) )
const ( const (
MainBuffer uint8 = 0 MainBuffer uint8 = 0
AltBuffer uint8 = 1 AltBuffer uint8 = 1
InternalBuffer uint8 = 2
) )
type MouseMode uint type MouseMode uint
@ -50,6 +51,7 @@ type Terminal struct {
isDirty bool isDirty bool
charWidth float32 charWidth float32
charHeight float32 charHeight float32
lastBuffer uint8
} }
type Modes struct { type Modes struct {
@ -77,6 +79,10 @@ func New(pty *os.File, logger *zap.SugaredLogger, config *config.Config) *Termin
FgColour: config.ColourScheme.Foreground, FgColour: config.ColourScheme.Foreground,
BgColour: config.ColourScheme.Background, BgColour: config.ColourScheme.Background,
}), }),
buffer.NewBuffer(1, 1, buffer.CellAttributes{
FgColour: config.ColourScheme.Foreground,
BgColour: config.ColourScheme.Background,
}),
}, },
pty: pty, pty: pty,
logger: logger, logger: logger,
@ -131,16 +137,30 @@ func (terminal *Terminal) UseAltBuffer() {
terminal.SetSize(uint(terminal.size.Width), uint(terminal.size.Height)) terminal.SetSize(uint(terminal.size.Width), uint(terminal.size.Height))
} }
func (terminal *Terminal) UseInternalBuffer() {
terminal.pauseChan <- true
terminal.activeBufferIndex = InternalBuffer
terminal.SetSize(uint(terminal.size.Width), uint(terminal.size.Height))
}
func (terminal *Terminal) ExitInternalBuffer() {
terminal.activeBufferIndex = terminal.lastBuffer
terminal.resumeChan <- true
}
func (terminal *Terminal) ActiveBuffer() *buffer.Buffer { func (terminal *Terminal) ActiveBuffer() *buffer.Buffer {
return terminal.buffers[terminal.activeBufferIndex] return terminal.buffers[terminal.activeBufferIndex]
} }
func (terminal *Terminal) UsingMainBuffer() bool {
return terminal.activeBufferIndex == MainBuffer
}
func (terminal *Terminal) GetScrollOffset() uint { func (terminal *Terminal) GetScrollOffset() uint {
return terminal.ActiveBuffer().GetScrollOffset() return terminal.ActiveBuffer().GetScrollOffset()
} }
func (terminal *Terminal) ScrollDown(lines uint16) { func (terminal *Terminal) ScrollDown(lines uint16) {
terminal.logger.Infof("Scrolling down %d", lines)
terminal.ActiveBuffer().ScrollDown(lines) terminal.ActiveBuffer().ScrollDown(lines)
} }
@ -151,7 +171,6 @@ func (terminal *Terminal) SetCharSize(w float32, h float32) {
} }
func (terminal *Terminal) ScrollUp(lines uint16) { func (terminal *Terminal) ScrollUp(lines uint16) {
terminal.logger.Infof("Scrolling up %d", lines)
terminal.ActiveBuffer().ScrollUp(lines) terminal.ActiveBuffer().ScrollUp(lines)
} }