diff --git a/Makefile b/Makefile index 6de5891..deb15d5 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,8 @@ launcher-windows: build-windows if exist "bin\windows\Aminal" rmdir /S /Q "bin\windows\Aminal" mkdir "bin\windows\Aminal\Versions\${VERSION}" go build -o "bin\windows\Aminal\${BINARY}.exe" -ldflags "-H windowsgui" "${GEN_SRC_DIR}\launcher" - copy ${BINARY}-windows-amd64.exe "bin\windows\Aminal\Versions\${VERSION}\${BINARY}.exe" /Y + windres -o aminal.syso aminal.rc + go build -o "bin\windows\Aminal\Versions\${VERSION}\${BINARY}.exe" -ldflags "-H windowsgui" IF "${WINDOWS_CODESIGNING_CERT_PW}"=="" ECHO Environment variable WINDOWS_CODESIGNING_CERT_PW is not defined. & exit 1 signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp bin\windows\Aminal\${BINARY}.exe signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /as /fd sha256 /td sha256 bin\windows\Aminal\${BINARY}.exe diff --git a/README.md b/README.md index bfe5e8a..26d14ff 100644 --- a/README.md +++ b/README.md @@ -114,28 +114,29 @@ shell = "/bin/bash" # The shell to run for the terminal session. Default search_url = "https://www.google.com/search?q=$QUERY" # The search engine to use for the "search selected text" action. Defaults to google. Set this to your own search url using $QUERY as the keywords to replace when searching. max_lines = 1000 # Maximum number of lines in the terminal buffer. copy_and_paste_with_mouse = true # Text selected with the mouse is copied to the clipboard on end selection, and is pasted on right mouse button click. +show_vertical_scrollbar = true # Whether to show the vertical scrollbar dpi-scale = 0.0 # Override DPI scale. Defaults to 0.0 (let Aminal determine the DPI scale itself). [colours] cursor = "#e8dfd6" foreground = "#e8dfd6" background = "#021b21" - black = "#032c36" - red = "#c2454e" - green = "#7cbf9e" - yellow = "#8a7a63" - blue = "#065f73" - magenta = "#ff5879" - cyan = "#44b5b1" - light_grey = "#f2f1b9" - dark_grey = "#3e4360" - light_red = "#ef5847" - light_green = "#a2db91" - light_yellow = "#beb090" - light_blue = "#61778d" - light_magenta = "#ff99a1" - light_cyan = "#9ed9d8" - white = "#f6f6c9" + black = "#000000" + red = "#800000" + green = "#008000" + yellow = "#808000" + blue = "#000080" + magenta = "#800080" + cyan = "#008080" + light_grey = "#f2f2f2" + dark_grey = "#808080" + light_red = "#ff0000" + light_green = "#00ff00" + light_yellow = "#ffff00" + light_blue = "#0000ff" + light_magenta = "#ff00ff" + light_cyan = "#00ffff" + white = "#ffffff" selection = "#333366" # Mouse selection background colour [keys] diff --git a/buffer/buffer.go b/buffer/buffer.go index 91993ba..6259cf2 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -226,7 +226,7 @@ func (buffer *Buffer) GetSelectedText(selectionRegionMode SelectionRegionMode) s maxX := int(buffer.terminalState.viewWidth) - 1 if row == start.Line { minX = start.Col - } else if !line.wrapped { + } else if !line.wrapped && !line.nobreak { builder.WriteString("\n") } if row == end.Line { @@ -529,6 +529,15 @@ func (buffer *Buffer) convertRawLineToViewLine(rawLine uint64) uint16 { return uint16(int(rawLine) - (rawHeight - int(buffer.terminalState.viewHeight))) } +func (buffer *Buffer) GetVPosition() int { + result := int(uint(buffer.Height()) - uint(buffer.ViewHeight()) - buffer.terminalState.scrollLinesFromBottom) + if result < 0 { + result = 0 + } + + return result +} + // Width returns the width of the buffer in columns func (buffer *Buffer) Width() uint16 { return buffer.terminalState.viewWidth @@ -556,7 +565,7 @@ func (buffer *Buffer) insertLine() { if !buffer.InScrollableRegion() { pos := buffer.RawLine() - maxLines := buffer.getMaxLines() + maxLines := buffer.GetMaxLines() newLineCount := uint64(len(buffer.lines) + 1) if newLineCount > maxLines { newLineCount = maxLines @@ -652,7 +661,7 @@ func (buffer *Buffer) Index() { if buffer.terminalState.cursorY >= buffer.ViewHeight()-1 { buffer.lines = append(buffer.lines, newLine()) - maxLines := buffer.getMaxLines() + maxLines := buffer.GetMaxLines() if uint64(len(buffer.lines)) > maxLines { copy(buffer.lines, buffer.lines[uint64(len(buffer.lines))-maxLines:]) buffer.lines = buffer.lines[:maxLines] @@ -706,6 +715,7 @@ func (buffer *Buffer) Write(runes ...rune) { buffer.NewLineEx(true) newLine := buffer.getCurrentLine() + newLine.setNoBreak(true) if len(newLine.cells) == 0 { newLine.Append(buffer.terminalState.DefaultCell(true)) } @@ -1126,7 +1136,7 @@ func (buffer *Buffer) ResizeView(width uint16, height uint16) { buffer.terminalState.ResetVerticalMargins() } -func (buffer *Buffer) getMaxLines() uint64 { +func (buffer *Buffer) GetMaxLines() uint64 { result := buffer.terminalState.maxLines if result < uint64(buffer.terminalState.viewHeight) { result = uint64(buffer.terminalState.viewHeight) diff --git a/buffer/line.go b/buffer/line.go index 270b26f..3919d44 100644 --- a/buffer/line.go +++ b/buffer/line.go @@ -6,12 +6,14 @@ import ( type Line struct { wrapped bool // whether line was wrapped onto from the previous one + nobreak bool // true if no line break at the beginning of the line cells []Cell } func newLine() Line { return Line{ wrapped: false, + nobreak: false, cells: []Cell{}, } } @@ -45,6 +47,10 @@ func (line *Line) setWrapped(wrapped bool) { line.wrapped = wrapped } +func (line *Line) setNoBreak(nobreak bool) { + line.nobreak = nobreak +} + func (line *Line) String() string { runes := []rune{} for _, cell := range line.cells { diff --git a/config/config.go b/config/config.go index 11add72..dff0785 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { SearchURL string `toml:"search_url"` MaxLines uint64 `toml:"max_lines"` CopyAndPasteWithMouse bool `toml:"copy_and_paste_with_mouse"` + ShowVerticalScrollbar bool `toml:"show_vertical_scrollbar"` // Developer options. DebugMode bool `toml:"debug"` diff --git a/config/defaults.go b/config/defaults.go index 31eb279..6a62c78 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -39,6 +39,7 @@ func DefaultConfig() *Config { SearchURL: "https://www.google.com/search?q=$QUERY", MaxLines: 1000, CopyAndPasteWithMouse: true, + ShowVerticalScrollbar: true, } } diff --git a/gui/fonts.go b/gui/fonts.go index 3e0aaf6..911be0e 100644 --- a/gui/fonts.go +++ b/gui/fonts.go @@ -8,14 +8,14 @@ import ( "github.com/liamg/aminal/glfont" ) -func (gui *GUI) getPackedFont(name string) (*glfont.Font, error) { +func (gui *GUI) getPackedFont(name string, actualWidth int, actualHeight int) (*glfont.Font, error) { box := packr.NewBox("./packed-fonts") fontBytes, err := box.Find(name) if err != nil { return nil, fmt.Errorf("packaged font '%s' could not be read: %s", name, err) } - font, err := glfont.LoadFont(bytes.NewReader(fontBytes), gui.fontScale*gui.dpiScale/gui.scale(), gui.width, gui.height) + font, err := glfont.LoadFont(bytes.NewReader(fontBytes), gui.fontScale*gui.dpiScale/gui.scale(), actualWidth, actualHeight) if err != nil { return nil, fmt.Errorf("font '%s' failed to load: %v", name, err) } @@ -23,16 +23,16 @@ func (gui *GUI) getPackedFont(name string) (*glfont.Font, error) { return font, nil } -func (gui *GUI) loadFonts() error { +func (gui *GUI) loadFonts(actualWidth int, actualHeight int) error { // from https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack - defaultFont, err := gui.getPackedFont("Hack Regular Nerd Font Complete.ttf") + defaultFont, err := gui.getPackedFont("Hack Regular Nerd Font Complete.ttf", actualWidth, actualHeight) if err != nil { return err } - boldFont, err := gui.getPackedFont("Hack Bold Nerd Font Complete.ttf") + boldFont, err := gui.getPackedFont("Hack Bold Nerd Font Complete.ttf", actualWidth, actualHeight) if err != nil { return err } diff --git a/gui/gui.go b/gui/gui.go index be228f5..b35a8b8 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -30,6 +30,18 @@ import ( const wakePeriod = time.Second / 120 const halfWakePeriod = wakePeriod / 2 +const ( + DefaultWindowWidth = 800 + DefaultWindowHeight = 600 +) + +type mouseEventsHandler interface { + mouseMoveCallback(g *GUI, px float64, py float64) + mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) + cursorEnterCallback(g *GUI, enter bool) + isMouseInside(px float64, py float64) bool +} + type GUI struct { window *glfw.Window logger *zap.SugaredLogger @@ -63,8 +75,15 @@ type GUI struct { leftClickTime time.Time leftClickCount int // number of clicks in a serie - single click, double click, or triple click mouseMovedAfterSelectionStarted bool - internalResize bool - selectionRegionMode buffer.SelectionRegionMode + + catchedMouseHandler mouseEventsHandler + mouseCatchedOnButton glfw.MouseButton + prevMouseEventHandler mouseEventsHandler + + internalResize bool + selectionRegionMode buffer.SelectionRegionMode + + vScrollbar *scrollbar mainThreadFunc chan func() } @@ -156,24 +175,33 @@ func New(config *config.Config, terminal *terminal.Terminal, logger *zap.Sugared } return &GUI{ - config: config, - logger: logger, - width: 800, - height: 600, - appliedWidth: 0, - appliedHeight: 0, - dpiScale: 1, - terminal: terminal, - fontScale: 10.0, - terminalAlpha: 1, - keyboardShortcuts: shortcuts, - resizeLock: &sync.Mutex{}, - internalResize: false, + config: config, + logger: logger, + width: DefaultWindowWidth, + height: DefaultWindowHeight, + appliedWidth: 0, + appliedHeight: 0, + dpiScale: 1, + terminal: terminal, + fontScale: 10.0, + terminalAlpha: 1, + keyboardShortcuts: shortcuts, + resizeLock: &sync.Mutex{}, + internalResize: false, + vScrollbar: nil, + catchedMouseHandler: nil, mainThreadFunc: make(chan func()), }, nil } +func (gui *GUI) Free() { + if gui.vScrollbar != nil { + gui.vScrollbar.Free() + gui.vScrollbar = nil + } +} + // inspired by https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl func (gui *GUI) scale() float32 { @@ -208,15 +236,20 @@ func (gui *GUI) resizeToTerminal() { gui.logger.Debugf("Initiating GUI resize to columns=%d rows=%d", newCols, newRows) gui.logger.Debugf("Calculating size...") - width, height := gui.renderer.GetRectangleSize(newCols, newRows) + width, height := gui.renderer.ConvertCoordinates(newCols, newRows) roundedWidth := int(math.Ceil(float64(width))) roundedHeight := int(math.Ceil(float64(height))) + if gui.vScrollbar != nil { + roundedWidth += int(gui.vScrollbar.position.width()) + } + gui.resizeCache = &ResizeCache{roundedWidth, roundedHeight, newCols, newRows} gui.logger.Debugf("Resizing window to %dx%d", roundedWidth, roundedHeight) gui.internalResize = true + gui.window.SetSize(roundedWidth, roundedHeight) // will trigger resize() gui.internalResize = false } @@ -278,14 +311,20 @@ func (gui *GUI) resize(w *glfw.Window, width int, height int) { gui.width = width gui.height = height - gui.appliedWidth = width - gui.appliedHeight = height + gui.appliedWidth = gui.width + gui.appliedHeight = gui.height + + vScrollbarWidth := 0 + if gui.vScrollbar != nil { + gui.vScrollbar.resize(gui) + vScrollbarWidth = int(gui.vScrollbar.position.width()) + } gui.logger.Debugf("Updating font resolutions...") - gui.loadFonts() + gui.loadFonts(gui.width, gui.height) gui.logger.Debugf("Setting renderer area...") - gui.renderer.SetArea(0, 0, gui.width, gui.height) + gui.renderer.SetArea(0, 0, gui.width-vScrollbarWidth, gui.height) if gui.resizeCache != nil && gui.resizeCache.Width == width && gui.resizeCache.Height == height { gui.logger.Debugf("No need to resize internal terminal!") @@ -334,18 +373,17 @@ func (gui *GUI) Close() { } func (gui *GUI) Render() error { - gui.logger.Debugf("Creating window...") var err error gui.window, err = gui.createWindow() - gui.SetDPIScale() - gui.window.SetSize(int(float32(gui.width)*gui.dpiScale), - int(float32(gui.height)*gui.dpiScale)) if err != nil { return fmt.Errorf("Failed to create window: %s", err) } defer glfw.Terminate() + gui.SetDPIScale() + gui.window.SetSize(int(float32(gui.width)*gui.dpiScale), int(float32(gui.height)*gui.dpiScale)) + gui.logger.Debugf("Initialising OpenGL and creating program...") program, err := gui.createProgram() if err != nil { @@ -355,8 +393,19 @@ func (gui *GUI) Render() error { gui.colourAttr = uint32(gl.GetAttribLocation(program, gl.Str("inColour\x00"))) gl.BindFragDataLocation(program, 0, gl.Str("outColour\x00")) + vScrollbarWidth := 0 + if gui.config.ShowVerticalScrollbar { + vScrollbar, err := newScrollbar() + if err != nil { + return err + } + gui.vScrollbar = vScrollbar + gui.vScrollbar.resize(gui) + vScrollbarWidth = int(gui.vScrollbar.position.width()) + } + gui.logger.Debugf("Loading font...") - if err := gui.loadFonts(); err != nil { + if err := gui.loadFonts(gui.width, gui.height); err != nil { return fmt.Errorf("Failed to load font: %s", err) } @@ -364,14 +413,18 @@ func (gui *GUI) Render() error { resizeChan := make(chan bool, 1) reverseChan := make(chan bool, 1) - gui.renderer = NewOpenGLRenderer(gui.config, gui.fontMap, 0, 0, gui.width, gui.height, gui.colourAttr, program) + gui.renderer, err = NewOpenGLRenderer(gui.config, gui.fontMap, 0, 0, gui.width-vScrollbarWidth, gui.height, gui.colourAttr, program) + if err != nil { + return err + } gui.window.SetFramebufferSizeCallback(gui.resize) gui.window.SetKeyCallback(gui.key) gui.window.SetCharCallback(gui.char) gui.window.SetScrollCallback(gui.glfwScrollCallback) - gui.window.SetMouseButtonCallback(gui.mouseButtonCallback) - gui.window.SetCursorPosCallback(gui.mouseMoveCallback) + gui.window.SetMouseButtonCallback(gui.globalMouseButtonCallback) + gui.window.SetCursorPosCallback(gui.globalMouseMoveCallback) + gui.window.SetCursorEnterCallback(gui.globalCursorEnterCallback) gui.window.SetRefreshCallback(func(w *glfw.Window) { gui.terminal.NotifyDirty() }) @@ -404,6 +457,10 @@ func (gui *GUI) Render() error { gui.Close() }() + if gui.vScrollbar != nil { + gui.vScrollbar.resize(gui) + } + gui.logger.Debugf("Starting render...") gl.UseProgram(program) @@ -412,16 +469,6 @@ func (gui *GUI) Render() error { gl.Disable(gl.DEPTH_TEST) gl.TexParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - go func() { - for { - <-ticker.C - gui.logger.Sync() - } - }() - gui.terminal.SetProgram(program) latestVersion := "" @@ -438,7 +485,9 @@ func (gui *GUI) Render() error { showMessage := true stop := make(chan struct{}) - go gui.waker(stop) + var waitForWaker sync.WaitGroup + waitForWaker.Add(1) + go gui.waker(stop, &waitForWaker) for !gui.window.ShouldClose() { gui.redraw(true) @@ -502,9 +551,13 @@ Buffer Size: %d lines } } - close(stop) // Tell waker to end. + gui.logger.Debug("Stopping render...") + + close(stop) // Tell waker to end... + waitForWaker.Wait() // ...and wait it to end + + gui.logger.Debug("Render stopped") - gui.logger.Debugf("Stopping render...") return nil } @@ -513,10 +566,13 @@ Buffer Size: %d lines // waking up the main thread when the GUI needs to be // redrawn. Limiting is applied on wakeups to avoid excessive CPU // usage when the terminal is being updated rapidly. -func (gui *GUI) waker(stop <-chan struct{}) { +func (gui *GUI) waker(stop <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + dirty := gui.terminal.Dirty() var nextWake <-chan time.Time var last time.Time +forLoop: for { select { case <-dirty: @@ -540,7 +596,7 @@ func (gui *GUI) waker(stop <-chan struct{}) { glfw.PostEmptyEvent() nextWake = nil case <-stop: - return + break forLoop } } } @@ -677,8 +733,9 @@ func (gui *GUI) renderTerminalData(shouldLock bool) { gui.renderer.DrawUnderline(span, uint(x-span), uint(y), colour) } } - } + + gui.renderScrollbar() } func (gui *GUI) redraw(shouldLock bool) { @@ -810,18 +867,27 @@ func (gui *GUI) SwapBuffers() { gui.window.SwapBuffers() } -func (gui *GUI) Screenshot(path string) { +func (gui *GUI) Screenshot(path string) error { x, y := gui.window.GetPos() w, h := gui.window.GetSize() img, err := screenshot.CaptureRect(image.Rectangle{Min: image.Point{X: x, Y: y}, Max: image.Point{X: x + w, Y: y + h}}) if err != nil { - panic(err) + return err + } + file, err := os.Create(path) + if err != nil { + return err } - file, _ := os.Create(path) defer file.Close() - png.Encode(file, img) + err = png.Encode(file, img) + if err != nil { + os.Remove(path) + return err + } + + return nil } func (gui *GUI) windowPosChangeCallback(w *glfw.Window, xpos int, ypos int) { @@ -832,6 +898,16 @@ func (gui *GUI) monitorChangeCallback(monitor *glfw.Monitor, event glfw.MonitorE gui.SetDPIScale() } +func (gui *GUI) renderScrollbar() { + if gui.vScrollbar != nil { + position := gui.terminal.ActiveBuffer().GetVPosition() + maxPosition := int(gui.terminal.ActiveBuffer().GetMaxLines()) - int(gui.terminal.ActiveBuffer().ViewHeight()) + + gui.vScrollbar.setPosition(maxPosition, position) + gui.vScrollbar.render(gui) + } +} + // Synchronously executes the argument function in the main thread. // Does not return until f() executed! func (gui *GUI) executeInMainThread(f func() error) error { diff --git a/gui/mouse.go b/gui/mouse.go index 14c71c5..19703e9 100644 --- a/gui/mouse.go +++ b/gui/mouse.go @@ -36,7 +36,86 @@ func (gui *GUI) getArrowCursor() *glfw.Cursor { return gui.arrowCursor } -func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) { +func (gui *GUI) scaleMouseCoordinates(px float64, py float64) (float64, float64) { + scale := float64(gui.scale()) + px = px / scale + py = py / scale + + return px, py +} + +func (gui *GUI) globalMouseMoveCallback(w *glfw.Window, px float64, py float64) { + px, py = gui.scaleMouseCoordinates(px, py) + + if gui.catchedMouseHandler != nil { + gui.catchedMouseHandler.mouseMoveCallback(gui, px, py) + } else { + if gui.isMouseInside(px, py) { + if gui.prevMouseEventHandler != gui { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + } + gui.cursorEnterCallback(gui, true) + } + gui.mouseMoveCallback(gui, px, py) + gui.prevMouseEventHandler = gui + } else if gui.vScrollbar != nil && gui.vScrollbar.isMouseInside(px, py) { + if gui.prevMouseEventHandler != gui.vScrollbar { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + } + gui.vScrollbar.cursorEnterCallback(gui, true) + } + gui.vScrollbar.mouseMoveCallback(gui, px, py) + gui.prevMouseEventHandler = gui.vScrollbar + } + } +} + +func (gui *GUI) globalMouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { + mouseX, mouseY := gui.scaleMouseCoordinates(w.GetCursorPos()) + + if gui.catchedMouseHandler != nil { + gui.catchedMouseHandler.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + if action == glfw.Release && button == gui.mouseCatchedOnButton { + gui.catchMouse(nil, 0) + } + } else { + + if gui.isMouseInside(mouseX, mouseY) { + if action == glfw.Press { + gui.catchMouse(gui, button) + } + gui.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + } else if gui.vScrollbar != nil && gui.vScrollbar.isMouseInside(mouseX, mouseY) { + if action == glfw.Press { + gui.catchMouse(gui.vScrollbar, button) + } + gui.vScrollbar.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + } + } +} + +func (gui *GUI) globalCursorEnterCallback(w *glfw.Window, entered bool) { + if !entered { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + gui.prevMouseEventHandler = nil + } + } +} + +func (gui *GUI) catchMouse(newHandler mouseEventsHandler, button glfw.MouseButton) { + gui.catchedMouseHandler = newHandler + gui.mouseCatchedOnButton = button +} + +func (gui *GUI) isMouseInside(px float64, py float64) bool { + return px >= float64(gui.renderer.areaX) && px < float64(gui.renderer.areaX+gui.renderer.areaWidth) && + py >= float64(gui.renderer.areaY) && py < float64(gui.renderer.areaY+gui.renderer.areaHeight) +} + +func (gui *GUI) mouseMoveCallback(g *GUI, px float64, py float64) { x, y := gui.convertMouseCoordinates(px, py) @@ -59,16 +138,13 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) { } if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { - w.SetCursor(gui.getHandCursor()) + gui.window.SetCursor(gui.getHandCursor()) } else { - w.SetCursor(gui.getArrowCursor()) + gui.window.SetCursor(gui.getArrowCursor()) } } func (gui *GUI) convertMouseCoordinates(px float64, py float64) (uint16, uint16) { - scale := gui.scale() - px = px / float64(scale) - py = py / float64(scale) 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()))) @@ -122,7 +198,7 @@ func btnCode(button glfw.MouseButton, release bool, mod glfw.ModifierKey) (b byt return b, true } -func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { +func (gui *GUI) mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) { if gui.overlay != nil { if button == glfw.MouseButtonRight && action == glfw.Release { @@ -132,7 +208,7 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act } // before we forward clicks on (below), we need to handle them locally for url clicking, text highlighting etc. - x, y := gui.convertMouseCoordinates(w.GetCursorPos()) + x, y := gui.convertMouseCoordinates(mouseX, mouseY) tx := int(x) + 1 // vt100 is 1 indexed ty := int(y) + 1 @@ -252,7 +328,10 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act default: panic("Unsupported mouse mode") } +} +func (gui *GUI) cursorEnterCallback(g *GUI, entered bool) { + // empty, just to conform to the mouseEventsHandler interface } func (gui *GUI) handleSelectionButtonPress(x uint16, y uint16, mod glfw.ModifierKey) { diff --git a/gui/rectangleRenderer.go b/gui/rectangleRenderer.go new file mode 100644 index 0000000..484fe51 --- /dev/null +++ b/gui/rectangleRenderer.go @@ -0,0 +1,157 @@ +package gui + +import ( + "github.com/go-gl/gl/all-core/gl" + "github.com/liamg/aminal/config" +) + +const ( + rectangleRendererVertexShaderSource = ` + #version 330 core + layout (location = 0) in vec2 position; + uniform vec2 resolution; + + void main() { + // convert from window coordinates to GL coordinates + vec2 glCoordinates = ((position / resolution) * 2.0 - 1.0) * vec2(1, -1); + + gl_Position = vec4(glCoordinates, 0.0, 1.0); + }` + "\x00" + + rectangleRendererFragmentShaderSource = ` + #version 330 core + uniform vec4 inColor; + out vec4 outColor; + void main() { + outColor = inColor; + }` + "\x00" +) + +type rectangleRenderer struct { + program uint32 + vbo uint32 + vao uint32 + ibo uint32 + uniformLocationResolution int32 + uniformLocationInColor int32 +} + +func createRectangleRendererProgram() (uint32, error) { + vertexShader, err := compileShader(rectangleRendererVertexShaderSource, gl.VERTEX_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(vertexShader) + + fragmentShader, err := compileShader(rectangleRendererFragmentShaderSource, gl.FRAGMENT_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(fragmentShader) + + prog := gl.CreateProgram() + gl.AttachShader(prog, vertexShader) + gl.AttachShader(prog, fragmentShader) + gl.LinkProgram(prog) + + return prog, nil +} + +func newRectangleRenderer() (*rectangleRenderer, error) { + prog, err := createRectangleRendererProgram() + if err != nil { + return nil, err + } + + var vbo uint32 + var vao uint32 + var ibo uint32 + + gl.GenBuffers(1, &vbo) + gl.GenVertexArrays(1, &vao) + gl.GenBuffers(1, &ibo) + + indices := [...]uint32{ + 0, 1, 2, + 2, 3, 0, + } + + gl.BindVertexArray(vao) + + gl.BindBuffer(gl.ARRAY_BUFFER, vbo) + gl.BufferData(gl.ARRAY_BUFFER, 8*4, nil, gl.DYNAMIC_DRAW) // just reserve data for the buffer + + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(indices)*4, gl.Ptr(&indices[0]), gl.DYNAMIC_DRAW) + + gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 2*4, nil) + gl.EnableVertexAttribArray(0) + + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + + return &rectangleRenderer{ + program: prog, + vbo: vbo, + vao: vao, + ibo: ibo, + uniformLocationResolution: gl.GetUniformLocation(prog, gl.Str("resolution\x00")), + uniformLocationInColor: gl.GetUniformLocation(prog, gl.Str("inColor\x00")), + }, nil +} + +func (rr *rectangleRenderer) Free() { + if rr.program != 0 { + gl.DeleteProgram(rr.program) + rr.program = 0 + } + + if rr.vbo != 0 { + gl.DeleteBuffers(1, &rr.vbo) + rr.vbo = 0 + } + + if rr.vao != 0 { + gl.DeleteBuffers(1, &rr.vao) + rr.vao = 0 + } + + if rr.ibo != 0 { + gl.DeleteBuffers(1, &rr.ibo) + rr.ibo = 0 + } +} + +func (rr *rectangleRenderer) render(left float32, top float32, width float32, height float32, colour config.Colour) { + var savedProgram int32 + gl.GetIntegerv(gl.CURRENT_PROGRAM, &savedProgram) + defer gl.UseProgram(uint32(savedProgram)) + + currentViewport := [4]int32{} + gl.GetIntegerv(gl.VIEWPORT, ¤tViewport[0]) + + gl.UseProgram(rr.program) + gl.Uniform2f(rr.uniformLocationResolution, float32(currentViewport[2]), float32(currentViewport[3])) + + gl.Uniform4f(rr.uniformLocationInColor, colour[0], colour[1], colour[2], 1.0) + + vertices := [...]float32{ + left, top, + left + width, top, + left + width, top + height, + left, top + height, + } + + /* + gl.NamedBufferSubData(rr.vbo, 0, len(vertices)*4, gl.Ptr(&vertices[0])) + /*/ + gl.BindBuffer(gl.ARRAY_BUFFER, rr.vbo) + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(&vertices[0])) + //*/ + + gl.BindVertexArray(rr.vao) + + gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0)) + + gl.BindVertexArray(0) +} diff --git a/gui/renderer.go b/gui/renderer.go index b5aee10..9b9e6ab 100644 --- a/gui/renderer.go +++ b/gui/renderer.go @@ -1,13 +1,12 @@ package gui import ( - "image" - "math" - "github.com/go-gl/gl/all-core/gl" "github.com/liamg/aminal/buffer" "github.com/liamg/aminal/config" "github.com/liamg/aminal/glfont" + "image" + "math" ) type OpenGLRenderer struct { @@ -26,16 +25,8 @@ type OpenGLRenderer struct { textureMap map[*image.RGBA]uint32 fontMap *FontMap backgroundColour [3]float32 -} -type rectangle struct { - vao uint32 - vbo uint32 - cv uint32 - colourAttr uint32 - colour [3]float32 - points [18]float32 - prog uint32 + rectRenderer *rectangleRenderer } func (r *OpenGLRenderer) CellWidth() float32 { @@ -46,95 +37,12 @@ func (r *OpenGLRenderer) CellHeight() float32 { return r.cellHeight } -func (r *OpenGLRenderer) newRectangleEx(x float32, y float32, width float32, height float32, colourAttr uint32) *rectangle { - - rect := &rectangle{} - - halfAreaWidth := float32(r.areaWidth / 2) - halfAreaHeight := float32(r.areaHeight / 2) - - x = (x - halfAreaWidth) / halfAreaWidth - y = -(y - (halfAreaHeight)) / halfAreaHeight - w := width / halfAreaWidth - h := height / halfAreaHeight - - rect.points = [18]float32{ - x, y, 0, - x, y + h, 0, - x + w, y + h, 0, - - x + w, y, 0, - x, y, 0, - x + w, y + h, 0, +func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY int, areaWidth int, areaHeight int, colourAttr uint32, program uint32) (*OpenGLRenderer, error) { + rectRenderer, err := newRectangleRenderer() + if err != nil { + return nil, err } - rect.colourAttr = colourAttr - rect.prog = r.program - - // SHAPE - gl.GenBuffers(1, &rect.vbo) - gl.BindBuffer(gl.ARRAY_BUFFER, rect.vbo) - gl.BufferData(gl.ARRAY_BUFFER, 4*len(rect.points), gl.Ptr(&rect.points[0]), gl.STATIC_DRAW) - - gl.GenVertexArrays(1, &rect.vao) - gl.BindVertexArray(rect.vao) - gl.EnableVertexAttribArray(0) - - gl.BindBuffer(gl.ARRAY_BUFFER, rect.vbo) - gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil) - - // colour - gl.GenBuffers(1, &rect.cv) - - rect.setColour([3]float32{0, 1, 0}) - - return rect -} - -func (r *OpenGLRenderer) newRectangle(x float32, y float32, colourAttr uint32) *rectangle { - return r.newRectangleEx(x, y, r.cellWidth, r.cellHeight, colourAttr) -} - -func (rect *rectangle) Draw() { - gl.UseProgram(rect.prog) - gl.BindVertexArray(rect.vao) - gl.DrawArrays(gl.TRIANGLES, 0, 6) -} - -func (rect *rectangle) setColour(colour [3]float32) { - if rect.colour == colour { - return - } - - c := []float32{ - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - } - - gl.UseProgram(rect.prog) - gl.BindBuffer(gl.ARRAY_BUFFER, rect.cv) - gl.BufferData(gl.ARRAY_BUFFER, len(c)*4, gl.Ptr(c), gl.STATIC_DRAW) - gl.EnableVertexAttribArray(rect.colourAttr) - gl.VertexAttribPointer(rect.colourAttr, 3, gl.FLOAT, false, 0, gl.PtrOffset(0)) - - rect.colour = colour -} - -func (rect *rectangle) Free() { - gl.DeleteVertexArrays(1, &rect.vao) - gl.DeleteBuffers(1, &rect.vbo) - gl.DeleteBuffers(1, &rect.cv) - - rect.vao = 0 - rect.vbo = 0 - rect.cv = 0 -} - -func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY int, areaWidth int, areaHeight int, colourAttr uint32, program uint32) *OpenGLRenderer { r := &OpenGLRenderer{ areaWidth: areaWidth, areaHeight: areaHeight, @@ -146,9 +54,10 @@ func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY program: program, textureMap: map[*image.RGBA]uint32{}, fontMap: fontMap, + rectRenderer: rectRenderer, } r.SetArea(areaX, areaY, areaWidth, areaHeight) - return r + return r, nil } // This method ensures that all OpenGL resources are deleted correctly @@ -162,6 +71,11 @@ func (r *OpenGLRenderer) Free() { gl.DeleteProgram(r.program) r.program = 0 + + if r.rectRenderer != nil { + r.rectRenderer.Free() + r.rectRenderer = nil + } } func (r *OpenGLRenderer) GetTermSize() (uint, uint) { @@ -181,26 +95,16 @@ func (r *OpenGLRenderer) SetArea(areaX int, areaY int, areaWidth int, areaHeight r.termRows = uint(math.Floor(float64(float32(r.areaHeight) / r.cellHeight))) } -func (r *OpenGLRenderer) GetRectangleSize(col uint, row uint) (float32, float32) { - x := float32(float32(col) * r.cellWidth) - y := float32(float32(row) * r.cellHeight) +func (r *OpenGLRenderer) ConvertCoordinates(col uint, row uint) (float32, float32) { + left := float32(float32(col) * r.cellWidth) + top := float32(float32(row) * r.cellHeight) - return x, y -} - -func (r *OpenGLRenderer) getRectangle(col uint, row uint) *rectangle { - x := float32(float32(col) * r.cellWidth) - y := float32(float32(row)*r.cellHeight) + r.cellHeight - - return r.newRectangle(x, y, r.colourAttr) + return left, top } func (r *OpenGLRenderer) DrawCursor(col uint, row uint, colour config.Colour) { - rect := r.getRectangle(col, row) - rect.setColour(colour) - rect.Draw() - - rect.Free() + left, top := r.ConvertCoordinates(col, row) + r.rectRenderer.render(left, top, r.cellWidth, r.cellHeight, colour) } func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, colour *config.Colour, force bool) { @@ -214,13 +118,9 @@ func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, colour } if bg != r.backgroundColour || force { - rect := r.getRectangle(col, row) - rect.setColour(bg) - rect.Draw() - - rect.Free() + left, top := r.ConvertCoordinates(col, row) + r.rectRenderer.render(left, top, r.cellWidth, r.cellHeight, bg) } - } // DrawUnderline draws a line under 'span' characters starting at (col, row) @@ -233,12 +133,8 @@ func (r *OpenGLRenderer) DrawUnderline(span int, col uint, row uint, colour [3]f if thickness < 1 { thickness = 1 } - rect := r.newRectangleEx(x, y, r.cellWidth*float32(span), thickness, r.colourAttr) - rect.setColour(colour) - rect.Draw() - - rect.Free() + r.rectRenderer.render(x, y, r.cellWidth*float32(span), thickness, colour) } func (r *OpenGLRenderer) DrawCellText(text string, col uint, row uint, alpha float32, colour [3]float32, bold bool) { diff --git a/gui/scrollbar.go b/gui/scrollbar.go new file mode 100644 index 0000000..a8b5c54 --- /dev/null +++ b/gui/scrollbar.go @@ -0,0 +1,519 @@ +package gui + +import ( + "github.com/go-gl/gl/all-core/gl" + "github.com/go-gl/glfw/v3.2/glfw" +) + +const ( + scrollbarVertexShaderSource = ` + #version 330 core + layout (location = 0) in vec2 position; + uniform vec2 resolution; + + void main() { + // convert from window coordinates to GL coordinates + vec2 glCoordinates = ((position / resolution) * 2.0 - 1.0) * vec2(1, -1); + + gl_Position = vec4(glCoordinates, 0.0, 1.0); + }` + "\x00" + + scrollbarFragmentShaderSource = ` + #version 330 core + uniform vec4 inColor; + out vec4 outColor; + void main() { + outColor = inColor; + }` + "\x00" + + NumberOfVertexValues = 100 +) + +var ( + scrollbarColor_Bg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ThumbNormal = [3]float32{float32(193) / float32(255), float32(193) / float32(255), float32(193) / float32(255)} + scrollbarColor_ThumbHover = [3]float32{float32(168) / float32(255), float32(168) / float32(255), float32(168) / float32(255)} + scrollbarColor_ThumbClicked = [3]float32{float32(120) / float32(255), float32(120) / float32(255), float32(120) / float32(255)} + + scrollbarColor_ButtonNormalBg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ButtonNormalFg = [3]float32{float32(80) / float32(255), float32(80) / float32(255), float32(80) / float32(255)} + + scrollbarColor_ButtonHoverBg = [3]float32{float32(210) / float32(255), float32(210) / float32(255), float32(210) / float32(255)} + scrollbarColor_ButtonHoverFg = [3]float32{float32(80) / float32(255), float32(80) / float32(255), float32(80) / float32(255)} + + scrollbarColor_ButtonDisabledBg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ButtonDisabledFg = [3]float32{float32(163) / float32(255), float32(163) / float32(255), float32(163) / float32(255)} + + scrollbarColor_ButtonClickedBg = [3]float32{float32(120) / float32(255), float32(120) / float32(255), float32(120) / float32(255)} + scrollbarColor_ButtonClickedFg = [3]float32{float32(255) / float32(255), float32(255) / float32(255), float32(255) / float32(255)} +) + +type scrollbarPart int + +const ( + None scrollbarPart = iota + UpperArrow + UpperSpace // the space between upper arrow and thumb + Thumb + BottomSpace // the space between thumb and bottom arrow + BottomArrow +) + +type ScreenRectangle struct { + left, top float32 // upper left corner in pixels relative to the window (in pixels) + right, bottom float32 +} + +func (sr *ScreenRectangle) width() float32 { + return sr.right - sr.left +} + +func (sr *ScreenRectangle) height() float32 { + return sr.bottom - sr.top +} + +func (sr *ScreenRectangle) isInside(x float32, y float32) bool { + return x >= sr.left && x < sr.right && + y >= sr.top && y < sr.bottom +} + +type scrollbar struct { + program uint32 + vbo uint32 + vao uint32 + uniformLocationResolution int32 + uniformLocationInColor int32 + + position ScreenRectangle // relative to the window's top left corner, in pixels + positionUpperArrow ScreenRectangle // relative to the control's top left corner + positionBottomArrow ScreenRectangle + positionThumb ScreenRectangle + + scrollPosition int + maxScrollPosition int + + thumbIsDragging bool + startedDraggingAtPosition int // scrollPosition when the dragging was started + startedDraggingAtThumbTop float32 // sb.positionThumb.top when the dragging was started + offsetInThumbY float32 // y offset inside the thumb of the dragging point + scrollPositionDelta int + + upperArrowIsDown bool + bottomArrowIsDown bool + + upperArrowFg []float32 + upperArrowBg []float32 + bottomArrowFg []float32 + bottomArrowBg []float32 + thumbColor []float32 +} + +// Returns the vertical scrollbar width in pixels +func getDefaultScrollbarWidth() int { + return 13 +} + +func createScrollbarProgram() (uint32, error) { + vertexShader, err := compileShader(scrollbarVertexShaderSource, gl.VERTEX_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(vertexShader) + + fragmentShader, err := compileShader(scrollbarFragmentShaderSource, gl.FRAGMENT_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(fragmentShader) + + prog := gl.CreateProgram() + gl.AttachShader(prog, vertexShader) + gl.AttachShader(prog, fragmentShader) + gl.LinkProgram(prog) + + return prog, nil +} + +func newScrollbar() (*scrollbar, error) { + prog, err := createScrollbarProgram() + if err != nil { + return nil, err + } + + var vbo uint32 + var vao uint32 + + gl.GenBuffers(1, &vbo) + gl.GenVertexArrays(1, &vao) + + gl.BindVertexArray(vao) + + gl.BindBuffer(gl.ARRAY_BUFFER, vbo) + gl.BufferData(gl.ARRAY_BUFFER, NumberOfVertexValues*4, nil, gl.DYNAMIC_DRAW) // only reserve the space + + gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 2*4, nil) + gl.EnableVertexAttribArray(0) + + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + + result := &scrollbar{ + program: prog, + vbo: vbo, + vao: vao, + uniformLocationResolution: gl.GetUniformLocation(prog, gl.Str("resolution\x00")), + uniformLocationInColor: gl.GetUniformLocation(prog, gl.Str("inColor\x00")), + + position: ScreenRectangle{ + right: 0, + bottom: 0, + left: 0, + top: 0, + }, + + scrollPosition: 0, + maxScrollPosition: 0, + + thumbIsDragging: false, + upperArrowIsDown: false, + bottomArrowIsDown: false, + } + + result.recalcElementPositions() + result.resetElementColors(-1, -1) // (-1, -1) ensures that no part is hovered by the mouse + + return result, nil +} + +func (sb *scrollbar) Free() { + if sb.program != 0 { + gl.DeleteProgram(sb.program) + sb.program = 0 + } + + if sb.vbo != 0 { + gl.DeleteBuffers(1, &sb.vbo) + sb.vbo = 0 + } + + if sb.vao != 0 { + gl.DeleteBuffers(1, &sb.vao) + sb.vao = 0 + } +} + +// Recalc positions of the scrollbar elements according to current +func (sb *scrollbar) recalcElementPositions() { + arrowHeight := sb.position.width() + + sb.positionUpperArrow = ScreenRectangle{ + left: 0, + top: 0, + right: sb.position.width(), + bottom: arrowHeight, + } + + sb.positionBottomArrow = ScreenRectangle{ + left: sb.positionUpperArrow.left, + top: sb.position.height() - arrowHeight, + right: sb.positionUpperArrow.right, + bottom: sb.position.height(), + } + thumbHeight := sb.position.width() + thumbTop := arrowHeight + if sb.maxScrollPosition != 0 { + thumbTop += (float32(sb.scrollPosition) * (sb.position.height() - thumbHeight - arrowHeight*2)) / float32(sb.maxScrollPosition) + } + + sb.positionThumb = ScreenRectangle{ + left: 2, + top: thumbTop, + right: sb.position.width() - 2, + bottom: thumbTop + thumbHeight, + } +} + +func (sb *scrollbar) resize(gui *GUI) { + sb.position.left = float32(gui.width) - float32(getDefaultScrollbarWidth())*gui.dpiScale + sb.position.top = float32(0.0) + sb.position.right = float32(gui.width) + sb.position.bottom = float32(gui.height - 1) + + sb.recalcElementPositions() + gui.terminal.NotifyDirty() +} + +func (sb *scrollbar) render(gui *GUI) { + var savedProgram int32 + gl.GetIntegerv(gl.CURRENT_PROGRAM, &savedProgram) + defer gl.UseProgram(uint32(savedProgram)) + + gl.UseProgram(sb.program) + gl.Uniform2f(sb.uniformLocationResolution, float32(gui.width), float32(gui.height)) + gl.BindVertexArray(sb.vao) + gl.BindBuffer(gl.ARRAY_BUFFER, sb.vbo) + defer func() { + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + }() + + // Draw background + gl.Uniform4f(sb.uniformLocationInColor, scrollbarColor_Bg[0], scrollbarColor_Bg[1], scrollbarColor_Bg[2], 1.0) + borderVertices := [...]float32{ + sb.position.left, sb.position.top, + sb.position.right, sb.position.top, + sb.position.right, sb.position.bottom, + + sb.position.right, sb.position.bottom, + sb.position.left, sb.position.bottom, + sb.position.left, sb.position.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(borderVertices)*4, gl.Ptr(&borderVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(borderVertices)/2)) + + // Draw upper arrow + // Upper arrow background + gl.Uniform4f(sb.uniformLocationInColor, sb.upperArrowBg[0], sb.upperArrowBg[1], sb.upperArrowBg[2], 1.0) + upperArrowBgVertices := [...]float32{ + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.top, + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.top, + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.bottom, + + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.bottom, + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.bottom, + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(upperArrowBgVertices)*4, gl.Ptr(&upperArrowBgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(upperArrowBgVertices)/2)) + + // Upper arrow foreground + gl.Uniform4f(sb.uniformLocationInColor, sb.upperArrowFg[0], sb.upperArrowFg[1], sb.upperArrowFg[2], 1.0) + upperArrowFgVertices := [...]float32{ + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()/2.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/3.0, + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()*2.0/3.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/2.0, + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()/3.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/2.0, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(upperArrowFgVertices)*4, gl.Ptr(&upperArrowFgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(upperArrowFgVertices)/2)) + + // Draw bottom arrow + // Bottom arrow background + gl.Uniform4f(sb.uniformLocationInColor, sb.bottomArrowBg[0], sb.bottomArrowBg[1], sb.bottomArrowBg[2], 1.0) + bottomArrowBgVertices := [...]float32{ + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.top, + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.top, + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.bottom, + + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.bottom, + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.bottom, + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(bottomArrowBgVertices)*4, gl.Ptr(&bottomArrowBgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(bottomArrowBgVertices)/2)) + + // Bottom arrow foreground + gl.Uniform4f(sb.uniformLocationInColor, sb.bottomArrowFg[0], sb.bottomArrowFg[1], sb.bottomArrowFg[2], 1.0) + bottomArrowFgVertices := [...]float32{ + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()/3.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()/2.0, + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()*2.0/3.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()/2.0, + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()/2.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()*2.0/3.0, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(bottomArrowFgVertices)*4, gl.Ptr(&bottomArrowFgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(bottomArrowFgVertices)/2)) + + // Draw thumb + gl.Uniform4f(sb.uniformLocationInColor, sb.thumbColor[0], sb.thumbColor[1], sb.thumbColor[2], 1.0) + thumbVertices := [...]float32{ + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.top, + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.top, + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.bottom, + + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.bottom, + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.bottom, + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(thumbVertices)*4, gl.Ptr(&thumbVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(thumbVertices)/2)) + + gui.terminal.NotifyDirty() +} + +func (sb *scrollbar) setPosition(max int, position int) { + if max <= 0 { + max = position + } + + if position > max { + position = max + } + + sb.maxScrollPosition = max + sb.scrollPosition = position + + sb.recalcElementPositions() +} + +func (sb *scrollbar) mouseHitTest(px float64, py float64) scrollbarPart { + // convert to local coordinates + mouseX := float32(px - float64(sb.position.left)) + mouseY := float32(py - float64(sb.position.top)) + + result := None + + if sb.positionUpperArrow.isInside(mouseX, mouseY) { + result = UpperArrow + } else if sb.positionBottomArrow.isInside(mouseX, mouseY) { + result = BottomArrow + } else if sb.positionThumb.isInside(mouseX, mouseY) { + result = Thumb + } else { + // construct UpperSpace + pos := ScreenRectangle{ + left: sb.positionThumb.left, + top: sb.positionUpperArrow.bottom, + right: sb.positionThumb.right, + bottom: sb.positionThumb.top, + } + + if pos.isInside(mouseX, mouseY) { + result = UpperSpace + } + + // now update it to be BottomSpace + pos.top = sb.positionThumb.bottom + pos.bottom = sb.positionBottomArrow.top + if pos.isInside(mouseX, mouseY) { + result = BottomSpace + } + } + + return result +} + +func (sb *scrollbar) isMouseInside(px float64, py float64) bool { + return sb.position.isInside(float32(px), float32(py)) +} + +func (sb *scrollbar) mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) { + if button == glfw.MouseButtonLeft { + if action == glfw.Press { + switch sb.mouseHitTest(mouseX, mouseY) { + case UpperArrow: + sb.upperArrowIsDown = true + g.terminal.ScreenScrollUp(1) + + case UpperSpace: + g.terminal.ScrollPageUp() + + case Thumb: + sb.thumbIsDragging = true + sb.startedDraggingAtPosition = sb.scrollPosition + sb.startedDraggingAtThumbTop = sb.positionThumb.top + sb.offsetInThumbY = float32(mouseY) - sb.position.top - sb.positionThumb.top + sb.scrollPositionDelta = 0 + + case BottomSpace: + g.terminal.ScrollPageDown() + + case BottomArrow: + sb.bottomArrowIsDown = true + g.terminal.ScreenScrollDown(1) + } + } else if action == glfw.Release { + if sb.thumbIsDragging { + sb.thumbIsDragging = false + } + + if sb.upperArrowIsDown { + sb.upperArrowIsDown = false + } + + if sb.bottomArrowIsDown { + sb.bottomArrowIsDown = false + } + } + + g.terminal.NotifyDirty() + } + + sb.resetElementColors(mouseX, mouseY) +} + +func (sb *scrollbar) mouseMoveCallback(g *GUI, px float64, py float64) { + sb.resetElementColors(px, py) + + if sb.thumbIsDragging { + py -= float64(sb.position.top) + + minThumbTop := sb.positionUpperArrow.bottom + maxThumbTop := sb.positionBottomArrow.top - sb.positionThumb.height() + + newThumbTop := float32(py) - sb.offsetInThumbY + + newPositionDelta := int((float32(sb.maxScrollPosition) * (newThumbTop - minThumbTop - sb.startedDraggingAtThumbTop)) / (maxThumbTop - minThumbTop)) + + if newPositionDelta > sb.scrollPositionDelta { + scrollLines := newPositionDelta - sb.scrollPositionDelta + g.logger.Debugf("old position: %d, new position delta: %d, scroll down %d lines", sb.scrollPosition, newPositionDelta, scrollLines) + g.terminal.ScreenScrollDown(uint16(scrollLines)) + sb.scrollPositionDelta = newPositionDelta + } else if newPositionDelta < sb.scrollPositionDelta { + scrollLines := sb.scrollPositionDelta - newPositionDelta + g.logger.Debugf("old position: %d, new position delta: %d, scroll up %d lines", sb.scrollPosition, newPositionDelta, scrollLines) + g.terminal.ScreenScrollUp(uint16(scrollLines)) + sb.scrollPositionDelta = newPositionDelta + } + + sb.recalcElementPositions() + g.logger.Debugf("new thumbTop: %f, fact thumbTop: %f, position: %d", newThumbTop, sb.positionThumb.top, sb.scrollPosition) + } + + g.terminal.NotifyDirty() +} + +func (sb *scrollbar) resetElementColors(mouseX float64, mouseY float64) { + part := sb.mouseHitTest(mouseX, mouseY) + + if sb.scrollPosition == 0 { + sb.upperArrowBg = scrollbarColor_ButtonDisabledBg[:] + sb.upperArrowFg = scrollbarColor_ButtonDisabledFg[:] + } else if sb.upperArrowIsDown { + sb.upperArrowFg = scrollbarColor_ButtonClickedFg[:] + sb.upperArrowBg = scrollbarColor_ButtonClickedBg[:] + } else if part == UpperArrow { + sb.upperArrowFg = scrollbarColor_ButtonHoverFg[:] + sb.upperArrowBg = scrollbarColor_ButtonHoverBg[:] + } else { + sb.upperArrowFg = scrollbarColor_ButtonNormalFg[:] + sb.upperArrowBg = scrollbarColor_ButtonNormalBg[:] + } + + if sb.scrollPosition == sb.maxScrollPosition { + sb.bottomArrowBg = scrollbarColor_ButtonDisabledBg[:] + sb.bottomArrowFg = scrollbarColor_ButtonDisabledFg[:] + } else if sb.bottomArrowIsDown { + sb.bottomArrowFg = scrollbarColor_ButtonClickedFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonClickedBg[:] + } else if part == BottomArrow { + sb.bottomArrowFg = scrollbarColor_ButtonHoverFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonHoverBg[:] + } else { + sb.bottomArrowFg = scrollbarColor_ButtonNormalFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonNormalBg[:] + } + + if sb.thumbIsDragging { + sb.thumbColor = scrollbarColor_ThumbClicked[:] + } else if part == Thumb { + sb.thumbColor = scrollbarColor_ThumbHover[:] + } else { + sb.thumbColor = scrollbarColor_ThumbNormal[:] + } +} + +func (sb *scrollbar) cursorEnterCallback(g *GUI, entered bool) { + if !entered { + sb.resetElementColors(-1, -1) // (-1, -1) ensures that no part is hovered by the mouse + g.terminal.NotifyDirty() + } +} diff --git a/main.go b/main.go index 1b4114c..63d6a69 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/liamg/aminal/platform" "github.com/liamg/aminal/terminal" "github.com/riywo/loginshell" + "time" ) type callback func(terminal *terminal.Terminal, g *gui.GUI) @@ -32,7 +33,17 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { fmt.Printf("Failed to create logger: %s\n", err) os.Exit(1) } - defer logger.Sync() + ticker := time.NewTicker(time.Second) + go func() { + for { + <-ticker.C + logger.Sync() + } + }() + defer func() { + ticker.Stop() + logger.Sync() + }() if conf.CPUProfile != "" { logger.Infof("Starting CPU profiling...") @@ -61,7 +72,7 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { guestProcess, err := pty.CreateGuestProcess(shellStr) if err != nil { pty.Close() - logger.Fatalf("Failed to start your shell: %s", err) + logger.Fatalf("Failed to start your shell %q: %s", shellStr, err) } defer guestProcess.Close() @@ -72,6 +83,7 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { if err != nil { logger.Fatalf("Cannot start: %s", err) } + defer g.Free() terminal.WindowManipulation = g diff --git a/main_test.go b/main_test.go index ab91005..e82de2d 100644 --- a/main_test.go +++ b/main_test.go @@ -11,11 +11,10 @@ import ( "testing" "time" + "github.com/carlogit/phash" "github.com/liamg/aminal/config" "github.com/liamg/aminal/gui" "github.com/liamg/aminal/terminal" - - "github.com/carlogit/phash" ) var termRef *terminal.Terminal @@ -47,10 +46,14 @@ func hash(path string) string { return imageHash } +func imagesAreEqual(expected string, actual string) int { + expectedHash := hash(expected) + actualHash := hash(actual) + return phash.GetDistance(expectedHash, actualHash) +} + func compareImages(expected string, actual string) { - template := hash(expected) - screen := hash(actual) - distance := phash.GetDistance(template, screen) + distance := imagesAreEqual(expected, actual) if distance != 0 { os.Exit(terminate(fmt.Sprintf("Screenshot \"%s\" doesn't match expected image \"%s\". Distance of hashes difference: %d\n", actual, expected, distance))) @@ -58,19 +61,55 @@ func compareImages(expected string, actual string) { } func send(terminal *terminal.Terminal, cmd string) { - terminal.Write([]byte(cmd)) + err := terminal.Write([]byte(cmd)) + if err != nil { + panic(err) + } } func enter(terminal *terminal.Terminal) { - terminal.Write([]byte("\n")) + err := terminal.Write([]byte("\n")) + if err != nil { + panic(err) + } } -func validateScreen(img string) { - guiRef.Screenshot(img) +func validateScreen(img string, waitForChange bool) { + fmt.Printf("taking screenshot: %s and comparing...", img) + + err := guiRef.Screenshot(img) + if err != nil { + panic(err) + } + compareImages(strings.Join([]string{"vttest/", img}, ""), img) + fmt.Printf("compare OK\n") + enter(termRef) - sleep() + + if waitForChange { + fmt.Print("Waiting for screen change...") + attempts := 10 + for { + sleep() + actualScren := "temp.png" + err = guiRef.Screenshot(actualScren) + if err != nil { + panic(err) + } + distance := imagesAreEqual(actualScren, img) + if distance != 0 { + break + } + fmt.Printf(" %d", attempts) + attempts-- + if attempts <= 0 { + break + } + } + fmt.Print("done\n") + } } func TestMain(m *testing.M) { @@ -115,12 +154,12 @@ func TestCursorMovement(t *testing.T) { os.Exit(terminate(fmt.Sprintf("ActiveBuffer doesn't match vttest template vttest/test-cursor-movement-1"))) } - validateScreen("test-cursor-movement-1.png") - validateScreen("test-cursor-movement-2.png") - validateScreen("test-cursor-movement-3.png") - validateScreen("test-cursor-movement-4.png") - validateScreen("test-cursor-movement-5.png") - validateScreen("test-cursor-movement-6.png") + validateScreen("test-cursor-movement-1.png", true) + validateScreen("test-cursor-movement-2.png", true) + validateScreen("test-cursor-movement-3.png", true) + validateScreen("test-cursor-movement-4.png", true) + validateScreen("test-cursor-movement-5.png", true) + validateScreen("test-cursor-movement-6.png", false) g.Close() } @@ -142,21 +181,21 @@ func TestScreenFeatures(t *testing.T) { send(term, "2\n") sleep() - validateScreen("test-screen-features-1.png") - validateScreen("test-screen-features-2.png") - validateScreen("test-screen-features-3.png") - validateScreen("test-screen-features-4.png") - validateScreen("test-screen-features-5.png") - validateScreen("test-screen-features-6.png") - validateScreen("test-screen-features-7.png") - validateScreen("test-screen-features-8.png") - validateScreen("test-screen-features-9.png") - validateScreen("test-screen-features-10.png") - validateScreen("test-screen-features-11.png") - validateScreen("test-screen-features-12.png") - validateScreen("test-screen-features-13.png") - validateScreen("test-screen-features-14.png") - validateScreen("test-screen-features-15.png") + validateScreen("test-screen-features-1.png", true) + validateScreen("test-screen-features-2.png", true) + validateScreen("test-screen-features-3.png", true) + validateScreen("test-screen-features-4.png", true) + validateScreen("test-screen-features-5.png", true) + validateScreen("test-screen-features-6.png", true) + validateScreen("test-screen-features-7.png", true) + validateScreen("test-screen-features-8.png", true) + validateScreen("test-screen-features-9.png", true) + validateScreen("test-screen-features-10.png", true) + validateScreen("test-screen-features-11.png", true) + validateScreen("test-screen-features-12.png", true) + validateScreen("test-screen-features-13.png", true) + validateScreen("test-screen-features-14.png", true) + validateScreen("test-screen-features-15.png", false) g.Close() } @@ -178,10 +217,9 @@ func TestSixel(t *testing.T) { send(term, "clear\n") sleep() send(term, "cat example.sixel\n") - sleep(4) + sleep(10) // Displaying SIXEL graphics *sometimes* takes long time. Excessive synchronization??? - guiRef.Screenshot("test-sixel.png") - validateScreen("test-sixel.png") + validateScreen("test-sixel.png", false) g.Close() } @@ -198,6 +236,9 @@ func TestExit(t *testing.T) { func testConfig() *config.Config { c := config.DefaultConfig() + // Force the scrollbar not showing when running unit tests + c.ShowVerticalScrollbar = false + // Use a vanilla shell on POSIX to help ensure consistency. if runtime.GOOS != "windows" { c.Shell = "/bin/sh" diff --git a/platform/winpty.go b/platform/winpty.go index 4bc1dc2..386ec79 100644 --- a/platform/winpty.go +++ b/platform/winpty.go @@ -4,12 +4,8 @@ package platform import ( "errors" - "syscall" - "time" - - "fmt" - "github.com/MaxRis/w32" + "syscall" ) // #include "windows.h" @@ -144,42 +140,7 @@ func (pty *winConPty) CreateGuestProcess(imagePath string) (Process, error) { pty.processID = process.processID - err = setupChildConsole(C.DWORD(process.processID), C.STD_OUTPUT_HANDLE, C.ENABLE_PROCESSED_OUTPUT|C.ENABLE_WRAP_AT_EOL_OUTPUT) - if err != nil { - process.Close() - return nil, err - } - - return process, err -} - -func setupChildConsole(processID C.DWORD, nStdHandle C.DWORD, mode uint) error { - C.FreeConsole() - defer C.AttachConsole(^C.DWORD(0)) // attach to parent process console - - // process may not be ready so we'll do retries - const maxWaitMilliSeconds = 5000 - const waitStepMilliSeconds = 200 - count := maxWaitMilliSeconds / waitStepMilliSeconds - - for { - if r := C.AttachConsole(processID); r != 0 { - break // success - } - lastError := C.GetLastError() - if lastError != C.ERROR_GEN_FAILURE || count <= 0 { - return fmt.Errorf("Was not able to attach to the child prosess' console") - } - - time.Sleep(time.Millisecond * time.Duration(waitStepMilliSeconds)) - count-- - } - - h := C.GetStdHandle(nStdHandle) - C.SetConsoleMode(h, C.DWORD(mode)) - C.FreeConsole() - - return nil + return process, nil } func (pty *winConPty) Resize(x, y int) error { diff --git a/terminal/ansi.go b/terminal/ansi.go index e56f697..ee181ac 100644 --- a/terminal/ansi.go +++ b/terminal/ansi.go @@ -17,6 +17,7 @@ var ansiSequenceMap = map[rune]escapeSequenceHandler{ 'P': sixelHandler, 'c': risHandler, //RIS '#': screenStateHandler, + '^': privacyMessageHandler, '(': scs0Handler, // select character set into G0 ')': scs1Handler, // select character set into G1 '*': swallowHandler(1), // character set bullshit @@ -104,3 +105,24 @@ func tabSetHandler(pty chan rune, terminal *Terminal) error { terminal.terminalState.TabSetAtCursor() return nil } + +func privacyMessageHandler(pty chan rune, terminal *Terminal) error { + // Handler should lock the terminal if there will be write operations to any data read by the renderer + // terminal.Lock() + // defer terminal.Unlock() + + isEscaped := false + for { + b := <-pty + if b == 0x18 /*CAN*/ || b == 0x1a /*SUB*/ || (b == 0x5c /*backslash*/ && isEscaped) { + break + } + if isEscaped { + isEscaped = false + } else if b == 0x1b { + isEscaped = true + continue + } + } + return nil +} diff --git a/windows/launcher/launcher.go b/windows/launcher/launcher.go index cd5cc5f..73098d8 100644 --- a/windows/launcher/launcher.go +++ b/windows/launcher/launcher.go @@ -23,10 +23,12 @@ import ( "io/ioutil" "os" "os/exec" + "os/user" "path/filepath" "sort" "strconv" "strings" + "syscall" ) type Version struct { @@ -42,8 +44,23 @@ func main() { versionsDir := filepath.Join(executableDir, "Versions") latestVersion, err := getLatestVersion(versionsDir) check(err) - target := filepath.Join(versionsDir, latestVersion, executableName) - cmd := exec.Command(target, os.Args[1:]...) + usr, err := user.Current() + check(err) + cmd := exec.Command("C:\\Windows\\System32\\cmd.exe", "/C", "start", "Aminal", "/B", executableName) + cmd.Dir = usr.HomeDir + latestVersionDir := filepath.Join(versionsDir, latestVersion) + path, pathSet := os.LookupEnv("PATH") + if pathSet { + path += ";" + latestVersionDir + } else { + path = latestVersionDir + } + cmd.Env = append(os.Environ(), "PATH="+path) + const CREATE_NO_WINDOW = 0x08000000 + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: CREATE_NO_WINDOW, + } check(cmd.Start()) }