diff --git a/Gopkg.lock b/Gopkg.lock index 9cfb085..2d82e7b 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -88,12 +88,12 @@ [[projects]] branch = "master" name = "golang.org/x/image" - packages = ["font","math/fixed"] + packages = ["bmp","font","math/fixed"] revision = "991ec62608f3c0da01d400756917825d1e2fd528" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "62428a3f67dc7a8a89ecb5666b6d8a6641788f89c61d0b0b4dd99982923bb0c6" + inputs-digest = "6f1eff05a1c5ca969d1f54eb8002e3213fda92b99a459e6729098b2497de5b8c" solver-name = "gps-cdcl" solver-version = 1 diff --git a/README.md b/README.md index 68c02c9..5339596 100644 --- a/README.md +++ b/README.md @@ -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. -Sixels are now supported. +## Contextual Hints + +![Example hint](hint.png) + +## Sixel Support ![Example sixel](sixel.png) @@ -55,13 +59,18 @@ Sixels are now supported. ## Keyboard Shortcuts -| Operation | Key(s) | -| ------------------ | ---------------- | -| Copy | ctrl + shift + c | -| Paste | ctrl + shift + v | -| Toggle slomo | ctrl + shift + ; | -| Interrupt (SIGINT) | ctrl + c | - +| Operation | Key(s) | +| --------------------- | -------------------- | +| Select text | click + drag | +| Select word | double click | +| Select line | triple click | +| 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 Aminal looks for a config file in `~/.aminal.toml`, and will write one there the first time it runs, if it doesn't already exist. diff --git a/buffer/hint.go b/buffer/hint.go new file mode 100644 index 0000000..51bf834 --- /dev/null +++ b/buffer/hint.go @@ -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) + +} diff --git a/gui/explain.go b/gui/explain.go new file mode 100644 index 0000000..a206b71 --- /dev/null +++ b/gui/explain.go @@ -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) + +} diff --git a/gui/gui.go b/gui/gui.go index 8f144a9..2409ca5 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -3,6 +3,7 @@ package gui import ( "bytes" "fmt" + "os/exec" "runtime" "time" @@ -18,29 +19,32 @@ import ( ) type GUI struct { - window *glfw.Window - logger *zap.SugaredLogger - config *config.Config - terminal *terminal.Terminal - width int //window width in pixels - height int //window height in pixels - font *glfont.Font - boldFont *glfont.Font - fontScale float32 - renderer *OpenGLRenderer - colourAttr uint32 - mouseDown bool + window *glfw.Window + logger *zap.SugaredLogger + config *config.Config + terminal *terminal.Terminal + width int //window width in pixels + height int //window height in pixels + font *glfont.Font + boldFont *glfont.Font + fontScale float32 + renderer *OpenGLRenderer + colourAttr uint32 + mouseDown bool + overlay overlay + terminalAlpha float32 } func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI { return &GUI{ - config: config, - logger: logger, - width: 800, - height: 600, - terminal: terminal, - fontScale: 14.0, + config: config, + logger: logger, + width: 800, + height: 600, + terminal: terminal, + 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) { if gui.renderer == nil { return 0, 0 @@ -242,9 +238,9 @@ func (gui *GUI) Render() error { 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 { - 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)) @@ -252,6 +248,8 @@ func (gui *GUI) Render() error { } } + gui.renderOverlay() + gui.window.SwapBuffers() } @@ -340,3 +338,19 @@ func (gui *GUI) createProgram() (uint32, error) { 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) + } +} diff --git a/gui/input.go b/gui/input.go index b5ebc62..289f72f 100644 --- a/gui/input.go +++ b/gui/input.go @@ -2,6 +2,7 @@ package gui import ( "fmt" + "net/url" "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) { + 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) modStr := "" @@ -39,6 +47,13 @@ func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Acti case glfw.KeyC: gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText()) 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: if s, err := gui.window.GetClipboardString(); err == nil { _ = gui.terminal.Paste([]byte(s)) diff --git a/gui/mouse.go b/gui/mouse.go index 28e53ef..208bdfa 100644 --- a/gui/mouse.go +++ b/gui/mouse.go @@ -3,20 +3,39 @@ package gui import ( "fmt" "math" - "os/exec" - "runtime" "github.com/go-gl/glfw/v3.2/glfw" "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) { px, py := w.GetCursorPos() 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()))) + if gui.mouseDown { 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 != "" { @@ -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) { + 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. px, py := w.GetCursorPos() 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 ty := int(y) + 1 - if action == glfw.Press { - gui.mouseDown = true - gui.terminal.ActiveBuffer().StartSelection(x, y) - } else if action == glfw.Release { - gui.mouseDown = false - gui.terminal.ActiveBuffer().EndSelection(x, y, true) - if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { - go gui.launchTarget(url) + if button == glfw.MouseButtonLeft { + + if action == glfw.Press { + gui.mouseDown = true + gui.terminal.ActiveBuffer().StartSelection(x, y) + } else if action == glfw.Release { + gui.mouseDown = false + 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 diff --git a/gui/overlays.go b/gui/overlays.go new file mode 100644 index 0000000..0aa996f --- /dev/null +++ b/gui/overlays.go @@ -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) +} diff --git a/gui/renderer.go b/gui/renderer.go index 9b15ed8..d713a88 100644 --- a/gui/renderer.go +++ b/gui/renderer.go @@ -195,7 +195,7 @@ func (r *OpenGLRenderer) DrawCursor(col uint, row uint, colour config.Colour) { 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 @@ -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.setColour(bg) 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 if colour != nil { fg = *colour + } else if cell.Attr().Reverse { + fg = cell.Bg() } else { - if cell.Attr().Reverse { - fg = cell.Bg() - } else { - fg = cell.Fg() - } + fg = cell.Fg() } - var alpha float32 = 1 if cell.Attr().Dim { - alpha = 0.5 + alpha = 0.5 * alpha } r.font.SetColor(fg[0], fg[1], fg[2], alpha) diff --git a/gui/textbox.go b/gui/textbox.go new file mode 100644 index 0000000..e778b72 --- /dev/null +++ b/gui/textbox.go @@ -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)) + } + +} diff --git a/hint.png b/hint.png new file mode 100644 index 0000000..b9744cb Binary files /dev/null and b/hint.png differ diff --git a/hints/hint.go b/hints/hint.go new file mode 100644 index 0000000..89866a7 --- /dev/null +++ b/hints/hint.go @@ -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 +} diff --git a/hints/perms.go b/hints/perms.go new file mode 100644 index 0000000..83ed533 --- /dev/null +++ b/hints/perms.go @@ -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) +} diff --git a/terminal/terminal.go b/terminal/terminal.go index 563572a..1dd404b 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -16,8 +16,9 @@ import ( ) const ( - MainBuffer uint8 = 0 - AltBuffer uint8 = 1 + MainBuffer uint8 = 0 + AltBuffer uint8 = 1 + InternalBuffer uint8 = 2 ) type MouseMode uint @@ -50,6 +51,7 @@ type Terminal struct { isDirty bool charWidth float32 charHeight float32 + lastBuffer uint8 } type Modes struct { @@ -77,6 +79,10 @@ func New(pty *os.File, logger *zap.SugaredLogger, config *config.Config) *Termin FgColour: config.ColourScheme.Foreground, BgColour: config.ColourScheme.Background, }), + buffer.NewBuffer(1, 1, buffer.CellAttributes{ + FgColour: config.ColourScheme.Foreground, + BgColour: config.ColourScheme.Background, + }), }, pty: pty, logger: logger, @@ -131,16 +137,30 @@ func (terminal *Terminal) UseAltBuffer() { 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 { return terminal.buffers[terminal.activeBufferIndex] } +func (terminal *Terminal) UsingMainBuffer() bool { + return terminal.activeBufferIndex == MainBuffer +} + func (terminal *Terminal) GetScrollOffset() uint { return terminal.ActiveBuffer().GetScrollOffset() } func (terminal *Terminal) ScrollDown(lines uint16) { - terminal.logger.Infof("Scrolling down %d", lines) terminal.ActiveBuffer().ScrollDown(lines) } @@ -151,7 +171,6 @@ func (terminal *Terminal) SetCharSize(w float32, h float32) { } func (terminal *Terminal) ScrollUp(lines uint16) { - terminal.logger.Infof("Scrolling up %d", lines) terminal.ActiveBuffer().ScrollUp(lines) }