#180/#234: CSI SM/RM multiple modes, Mouse button-tracking (#233)

* #180: added support for several modes specification in CSI SM/RM

* added Button Mouse Mode and SGR encoding
This commit is contained in:
rrrooommmaaa 2019-02-26 16:14:51 +03:00 committed by Liam Galvin
parent 8c4c842ff1
commit 86627135dd
5 changed files with 249 additions and 82 deletions

View File

@ -40,6 +40,7 @@ type GUI struct {
renderer *OpenGLRenderer renderer *OpenGLRenderer
colourAttr uint32 colourAttr uint32
mouseDown bool mouseDown bool
mouseDownModifier glfw.ModifierKey
overlay overlay overlay overlay
terminalAlpha float32 terminalAlpha float32
showDebugInfo bool showDebugInfo bool
@ -51,6 +52,8 @@ type GUI struct {
prevLeftClickX uint16 prevLeftClickX uint16
prevLeftClickY uint16 prevLeftClickY uint16
prevMotionTX int
prevMotionTY int
leftClickTime time.Time leftClickTime time.Time
leftClickCount int // number of clicks in a serie - single click, double click, or triple click leftClickCount int // number of clicks in a serie - single click, double click, or triple click
mouseMovedAfterSelectionStarted bool mouseMovedAfterSelectionStarted bool

View File

@ -4,10 +4,11 @@ import (
"fmt" "fmt"
"math" "math"
"time"
"github.com/go-gl/glfw/v3.2/glfw" "github.com/go-gl/glfw/v3.2/glfw"
"github.com/liamg/aminal/buffer" "github.com/liamg/aminal/buffer"
"github.com/liamg/aminal/terminal" "github.com/liamg/aminal/terminal"
"time"
) )
func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) { func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) {
@ -40,7 +41,13 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) {
x, y := gui.convertMouseCoordinates(px, py) x, y := gui.convertMouseCoordinates(px, py)
if gui.mouseDown { if gui.mouseDown {
gui.terminal.ActiveBuffer().ExtendSelection(x, y, false) if gui.terminal.GetMouseMode() == terminal.MouseModeButtonEvent {
tx := int(x) + 1 // vt100 is 1 indexed
ty := int(y) + 1
gui.emitButtonEventToTerminal(tx, ty, glfw.MouseButtonLeft, nil, gui.mouseDownModifier)
} else {
gui.terminal.ActiveBuffer().ExtendSelection(x, y, false)
}
} else { } else {
hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y) hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y)
@ -87,6 +94,34 @@ func (gui *GUI) updateLeftClickCount(x uint16, y uint16) int {
return gui.leftClickCount return gui.leftClickCount
} }
func btnCode(button glfw.MouseButton, release bool, mod glfw.ModifierKey) (b byte, ok bool) {
if release {
b = 3
} else {
switch button {
case glfw.MouseButton1:
b = 0
case glfw.MouseButton2:
b = 1
case glfw.MouseButton3:
b = 2
default:
return 0, false
}
}
if mod&glfw.ModShift > 0 {
b |= 4
}
if mod&glfw.ModSuper > 0 {
b |= 8
}
if mod&glfw.ModControl > 0 {
b |= 16
}
return b, true
}
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 gui.overlay != nil {
@ -101,56 +136,28 @@ 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
activeBuffer := gui.terminal.ActiveBuffer()
switch button { switch button {
case glfw.MouseButtonLeft: case glfw.MouseButtonLeft:
if action == glfw.Press { if action == glfw.Press {
gui.mouseDownModifier = mod
gui.mouseDown = true gui.mouseDown = true
clickCount := gui.updateLeftClickCount(x, y) if gui.terminal.GetMouseMode() != terminal.MouseModeButtonEvent {
switch clickCount { gui.handleSelectionButtonPress(x, y)
case 1:
activeBuffer.StartSelection(x, y, buffer.SelectionChar)
case 2:
activeBuffer.StartSelection(x, y, buffer.SelectionWord)
case 3:
activeBuffer.StartSelection(x, y, buffer.SelectionLine)
} }
gui.mouseMovedAfterSelectionStarted = false
} else if action == glfw.Release { } else if action == glfw.Release {
gui.mouseDown = false gui.mouseDown = false
if x != gui.prevLeftClickX || y != gui.prevLeftClickY { if gui.terminal.GetMouseMode() != terminal.MouseModeButtonEvent {
gui.mouseMovedAfterSelectionStarted = true gui.handleSelectionButtonRelease(x, y)
}
if gui.leftClickCount != 1 || gui.mouseMovedAfterSelectionStarted {
activeBuffer.ExtendSelection(x, y, true)
}
// Do copy to clipboard *or* open URL, but not both.
handled := false
if gui.config.CopyAndPasteWithMouse {
selectedText := activeBuffer.GetSelectedText()
if selectedText != "" {
gui.window.SetClipboardString(selectedText)
handled = true
}
}
if !handled {
if url := activeBuffer.GetURLAtPosition(x, y); url != "" {
go gui.launchTarget(url)
}
} }
} }
case glfw.MouseButtonRight: case glfw.MouseButtonRight:
if gui.config.CopyAndPasteWithMouse && action == glfw.Press { if gui.config.CopyAndPasteWithMouse && action == glfw.Press && gui.terminal.GetMouseMode() == terminal.MouseModeNone {
str, err := gui.window.GetClipboardString() str, err := gui.window.GetClipboardString()
if err == nil { if err == nil {
activeBuffer := gui.terminal.ActiveBuffer()
activeBuffer.ClearSelection() activeBuffer.ClearSelection()
_ = gui.terminal.Paste([]byte(str)) _ = gui.terminal.Paste([]byte(str))
} }
@ -214,36 +221,7 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act
Wheel mice may return buttons 4 and 5. Those buttons are represented by the same event codes as buttons 1 and 2 respectively, except that 64 is added to the event code. Release events for the wheel buttons are not reported. Wheel mice may return buttons 4 and 5. Those buttons are represented by the same event codes as buttons 1 and 2 respectively, except that 64 is added to the event code. Release events for the wheel buttons are not reported.
*/ */
var b byte gui.emitButtonEventToTerminal(tx, ty, button, &action, mod)
if action == glfw.Press {
switch button {
case glfw.MouseButton1:
b = 0
case glfw.MouseButton2:
b = 1
case glfw.MouseButton3:
b = 2
default:
return
}
} else if action == glfw.Release {
b = 3
} else {
return
}
if mod&glfw.ModShift > 0 {
b |= 4
}
if mod&glfw.ModSuper > 0 {
b |= 8
}
if mod&glfw.ModControl > 0 {
b |= 16
}
packet := fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(tx + 32)), (rune(ty + 32)))
gui.logger.Infof("Sending mouse packet: '%v'", packet)
gui.terminal.Write([]byte(packet))
case terminal.MouseModeVT200Highlight: case terminal.MouseModeVT200Highlight:
/* /*
@ -253,9 +231,15 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act
case terminal.MouseModeButtonEvent: case terminal.MouseModeButtonEvent:
/* /*
Button-event tracking is essentially the same as normal tracking, but xterm also reports button-motion events. Motion events are reported only if the mouse pointer has moved to a different character cell. It is enabled by specifying parameter 1002 to DECSET. On button press or release, xterm sends the same codes used by normal tracking mode. On button-motion events, xterm adds 32 to the event code (the third character, C b ). The other bits of the event code specify button and modifier keys as in normal mode. For example, motion into cell x,y with button 1 down is reported as CSI M @ C x C y . ( @ = 32 + 0 (button 1) + 32 (motion indicator) ). Similarly, motion with button 3 down is reported as CSI M B C x C y . ( B = 32 + 2 (button 3) + 32 (motion indicator) ). Button-event tracking is essentially the same as normal tracking, but xterm also reports button-motion events.
Motion events are reported only if the mouse pointer has moved to a different character cell. It is enabled by specifying parameter 1002 to DECSET.
On button press or release, xterm sends the same codes used by normal tracking mode.
On button-motion events, xterm adds 32 to the event code (the third character, C b ).
The other bits of the event code specify button and modifier keys as in normal mode.
For example, motion into cell x,y with button 1 down is reported as CSI M @ C x C y . ( @ = 32 + 0 (button 1) + 32 (motion indicator) ).
Similarly, motion with button 3 down is reported as CSI M B C x C y . ( B = 32 + 2 (button 3) + 32 (motion indicator) ).
*/ */
panic("Mouse button event mode not supported") gui.emitButtonEventToTerminal(tx, ty, button, &action, mod)
case terminal.MouseModeAnyEvent: case terminal.MouseModeAnyEvent:
/* /*
@ -270,3 +254,93 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act
} }
} }
func (gui *GUI) handleSelectionButtonPress(x uint16, y uint16) {
activeBuffer := gui.terminal.ActiveBuffer()
clickCount := gui.updateLeftClickCount(x, y)
switch clickCount {
case 1:
activeBuffer.StartSelection(x, y, buffer.SelectionChar)
case 2:
activeBuffer.StartSelection(x, y, buffer.SelectionWord)
case 3:
activeBuffer.StartSelection(x, y, buffer.SelectionLine)
}
gui.mouseMovedAfterSelectionStarted = false
}
func (gui *GUI) handleSelectionButtonRelease(x uint16, y uint16) {
activeBuffer := gui.terminal.ActiveBuffer()
if x != gui.prevLeftClickX || y != gui.prevLeftClickY {
gui.mouseMovedAfterSelectionStarted = true
}
if gui.leftClickCount != 1 || gui.mouseMovedAfterSelectionStarted {
activeBuffer.ExtendSelection(x, y, true)
}
// Do copy to clipboard *or* open URL, but not both.
handled := false
if gui.config.CopyAndPasteWithMouse {
selectedText := activeBuffer.GetSelectedText()
if selectedText != "" {
gui.window.SetClipboardString(selectedText)
handled = true
}
}
if !handled {
if url := activeBuffer.GetURLAtPosition(x, y); url != "" {
go gui.launchTarget(url)
}
}
}
func (gui *GUI) emitButtonEventToTerminal(tx int, ty int, button glfw.MouseButton, action *glfw.Action, mod glfw.ModifierKey) {
motion := action == nil
release := false
if !motion {
if *action == glfw.Release {
release = true
} else if *action != glfw.Press {
return
}
}
ext := gui.terminal.GetMouseExtMode()
// For SGR, normal button encoding (as for Press event)
b, ok := btnCode(button, release && ext != terminal.MouseExtSGR, mod)
if !ok {
return // unknown button
}
// @todo check limits for non-SGR encoding
if motion {
b |= 32
// after applying limits we can check the final values
if tx == gui.prevMotionTX && ty == gui.prevMotionTY {
return
}
}
gui.prevMotionTX = tx
gui.prevMotionTY = ty
var packet string
if ext == terminal.MouseExtSGR {
final := 'M'
if release {
final = 'm'
}
packet = fmt.Sprintf("\x1b[<%d;%d;%d%c", b, tx, ty, final)
} else {
packet = fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(tx + 32)), (rune(ty + 32)))
}
gui.logger.Infof("Sending mouse packet: '%v'", packet)
gui.terminal.Write([]byte(packet))
}

View File

@ -25,8 +25,8 @@ var csiSequences = []csiMapping{
{id: 'd', handler: csiLinePositionAbsolute, expectedParams: &expectedParams{min: 0, max: 1}, description: "Line Position Absolute [row] (default = [1,column]) (VPA)"}, {id: 'd', handler: csiLinePositionAbsolute, expectedParams: &expectedParams{min: 0, max: 1}, description: "Line Position Absolute [row] (default = [1,column]) (VPA)"},
{id: 'f', handler: csiCursorPositionHandler, description: "Horizontal and Vertical Position [row;column] (default = [1,1]) (HVP)"}, {id: 'f', handler: csiCursorPositionHandler, description: "Horizontal and Vertical Position [row;column] (default = [1,1]) (HVP)"},
{id: 'g', handler: csiTabClearHandler, description: "Tab Clear (TBC)"}, {id: 'g', handler: csiTabClearHandler, description: "Tab Clear (TBC)"},
{id: 'h', handler: csiSetModeHandler, expectedParams: &expectedParams{min: 1, max: 1}, description: "Set Mode (SM)"}, {id: 'h', handler: csiSetModeHandler, expectedParams: &expectedParams{min: 1, max: ^uint8(0)}, description: "Set Mode (SM)"},
{id: 'l', handler: csiResetModeHandler, expectedParams: &expectedParams{min: 1, max: 1}, description: "Reset Mode (RM)"}, {id: 'l', handler: csiResetModeHandler, expectedParams: &expectedParams{min: 1, max: ^uint8(0)}, description: "Reset Mode (RM)"},
{id: 'm', handler: sgrSequenceHandler, description: "Character Attributes (SGR)"}, {id: 'm', handler: sgrSequenceHandler, description: "Character Attributes (SGR)"},
{id: 'n', handler: csiDeviceStatusReportHandler, description: "Device Status Report (DSR)"}, {id: 'n', handler: csiDeviceStatusReportHandler, description: "Device Status Report (DSR)"},
{id: 'r', handler: csiSetMarginsHandler, expectedParams: &expectedParams{min: 0, max: 2}, description: "Set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM), VT100"}, {id: 'r', handler: csiSetMarginsHandler, expectedParams: &expectedParams{min: 0, max: 2}, description: "Set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM), VT100"},
@ -415,11 +415,11 @@ func csiEraseCharactersHandler(params []string, terminal *Terminal) error {
func csiResetModeHandler(params []string, terminal *Terminal) error { func csiResetModeHandler(params []string, terminal *Terminal) error {
terminal.ActiveBuffer().ClearSelection() terminal.ActiveBuffer().ClearSelection()
return csiSetMode(strings.Join(params, ""), false, terminal) return csiSetModes(params, false, terminal)
} }
func csiSetModeHandler(params []string, terminal *Terminal) error { func csiSetModeHandler(params []string, terminal *Terminal) error {
return csiSetMode(strings.Join(params, ""), true, terminal) return csiSetModes(params, true, terminal)
} }
func csiWindowManipulation(params []string, terminal *Terminal) error { func csiWindowManipulation(params []string, terminal *Terminal) error {

View File

@ -1,6 +1,51 @@
package terminal package terminal
import "fmt" import (
"errors"
"fmt"
"strings"
)
func recoverCodeFromEnabled(enabled bool) string {
code := ""
if enabled {
code = "h"
} else {
code = "l"
}
return code
}
func csiSetModes(modes []string, enabled bool, terminal *Terminal) error {
if len(modes) == 0 {
return fmt.Errorf("CSI %s without parameters is not allowed", recoverCodeFromEnabled(enabled))
}
if len(modes) == 1 {
return csiSetMode(modes[0], enabled, terminal)
}
// should we propagate DEC prefix?
const decPrefix = '?'
isDec := len(modes[0]) > 0 && modes[0][0] == decPrefix
// iterate through params, propagating DEC prefix to subsequent elements
errorStrings := make([]string, 0)
for i, v := range modes {
updatedMode := v
if i > 0 && isDec {
updatedMode = string(decPrefix) + v
}
err := csiSetMode(updatedMode, enabled, terminal)
if err != nil {
errorStrings = append(errorStrings, err.Error())
}
}
if len(errorStrings) > 0 {
return fmt.Errorf(strings.Join(errorStrings, "\n"))
}
return nil
}
func csiSetMode(modeStr string, enabled bool, terminal *Terminal) error { func csiSetMode(modeStr string, enabled bool, terminal *Terminal) error {
@ -79,7 +124,7 @@ func csiSetMode(modeStr string, enabled bool, terminal *Terminal) error {
} else { } else {
terminal.UseMainBuffer() terminal.UseMainBuffer()
} }
case "?1000", "?1006;1000", "?10061000": // ?10061000 seen from htop case "?1000", "?10061000": // ?10061000 seen from htop
// enable mouse tracking // enable mouse tracking
// 1000 refers to ext mode for extended mouse click area - otherwise only x <= 255-31 // 1000 refers to ext mode for extended mouse click area - otherwise only x <= 255-31
if enabled { if enabled {
@ -89,6 +134,44 @@ func csiSetMode(modeStr string, enabled bool, terminal *Terminal) error {
terminal.logger.Infof("Turning off VT200 mouse mode") terminal.logger.Infof("Turning off VT200 mouse mode")
terminal.SetMouseMode(MouseModeNone) terminal.SetMouseMode(MouseModeNone)
} }
case "?1002":
if enabled {
terminal.logger.Infof("Turning on Button Event mouse mode")
terminal.SetMouseMode(MouseModeButtonEvent)
} else {
terminal.logger.Infof("Turning off Button Event mouse mode")
terminal.SetMouseMode(MouseModeNone)
}
case "?1003":
return errors.New("Any Event mouse mode is not supported")
/*
if enabled {
terminal.logger.Infof("Turning on Any Event mouse mode")
terminal.SetMouseMode(MouseModeAnyEvent)
} else {
terminal.logger.Infof("Turning off Any Event mouse mode")
terminal.SetMouseMode(MouseModeNone)
}
*/
case "?1005":
return errors.New("UTF-8 ext mouse mode is not supported")
/*
if enabled {
terminal.logger.Infof("Turning on UTF-8 ext mouse mode")
terminal.SetMouseExtMode(MouseExtUTF)
} else {
terminal.logger.Infof("Turning off UTF-8 ext mouse mode")
terminal.SetMouseExtMode(MouseExtNone)
}
*/
case "?1006":
if enabled {
terminal.logger.Infof("Turning on SGR ext mouse mode")
terminal.SetMouseExtMode(MouseExtSGR)
} else {
terminal.logger.Infof("Turning off SGR ext mouse mode")
terminal.SetMouseExtMode(MouseExtNone)
}
case "?1048": case "?1048":
if enabled { if enabled {
terminal.ActiveBuffer().SaveCursor() terminal.ActiveBuffer().SaveCursor()
@ -104,13 +187,7 @@ func csiSetMode(modeStr string, enabled bool, terminal *Terminal) error {
case "?2004": case "?2004":
terminal.SetBracketedPasteMode(enabled) terminal.SetBracketedPasteMode(enabled)
default: default:
code := "" return fmt.Errorf("Unsupported CSI %s%s code", modeStr, recoverCodeFromEnabled(enabled))
if enabled {
code = "h"
} else {
code = "l"
}
return fmt.Errorf("Unsupported CSI %s%s code", modeStr, code)
} }
return nil return nil

View File

@ -19,6 +19,7 @@ const (
) )
type MouseMode uint type MouseMode uint
type MouseExtMode uint
const ( const (
MouseModeNone MouseMode = iota MouseModeNone MouseMode = iota
@ -27,6 +28,9 @@ const (
MouseModeVT200Highlight MouseModeVT200Highlight
MouseModeButtonEvent MouseModeButtonEvent
MouseModeAnyEvent MouseModeAnyEvent
MouseExtNone MouseExtMode = iota
MouseExtUTF
MouseExtSGR
) )
type Terminal struct { type Terminal struct {
@ -44,6 +48,7 @@ type Terminal struct {
reverseHandlers []chan bool reverseHandlers []chan bool
modes Modes modes Modes
mouseMode MouseMode mouseMode MouseMode
mouseExtMode MouseExtMode
bracketedPasteMode bool bracketedPasteMode bool
isDirty bool isDirty bool
charWidth float32 charWidth float32
@ -121,6 +126,14 @@ func (terminal *Terminal) GetMouseMode() MouseMode {
return terminal.mouseMode return terminal.mouseMode
} }
func (terminal *Terminal) SetMouseExtMode(mode MouseExtMode) {
terminal.mouseExtMode = mode
}
func (terminal *Terminal) GetMouseExtMode() MouseExtMode {
return terminal.mouseExtMode
}
func (terminal *Terminal) IsOSCTerminator(char rune) bool { func (terminal *Terminal) IsOSCTerminator(char rune) bool {
_, ok := terminal.platformDependentSettings.OSCTerminators[char] _, ok := terminal.platformDependentSettings.OSCTerminators[char]
return ok return ok