This commit is contained in:
2023-04-30 13:45:21 -04:00
parent b3a9bba255
commit 09d360826b
18 changed files with 38 additions and 51 deletions

2
plugins/x/x/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package x implements an X11 backend.
package x

390
plugins/x/x/encoding.go Normal file
View File

@@ -0,0 +1,390 @@
package x
import "unicode"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/keybind"
import "git.tebibyte.media/sashakoshka/tomo/input"
// when making changes to this file, look at keysymdef.h and
// https://tronche.com/gui/x/xlib/input/keyboard-encoding.html
var buttonCodeTable = map[xproto.Keysym] input.Key {
0xFFFFFF: input.KeyNone,
0xFF63: input.KeyInsert,
0xFF67: input.KeyMenu,
0xFF61: input.KeyPrintScreen,
0xFF6B: input.KeyPause,
0xFFE5: input.KeyCapsLock,
0xFF14: input.KeyScrollLock,
0xFF7F: input.KeyNumLock,
0xFF08: input.KeyBackspace,
0xFF09: input.KeyTab,
0xFE20: input.KeyTab,
0xFF0D: input.KeyEnter,
0xFF1B: input.KeyEscape,
0xFF52: input.KeyUp,
0xFF54: input.KeyDown,
0xFF51: input.KeyLeft,
0xFF53: input.KeyRight,
0xFF55: input.KeyPageUp,
0xFF56: input.KeyPageDown,
0xFF50: input.KeyHome,
0xFF57: input.KeyEnd,
0xFFE1: input.KeyLeftShift,
0xFFE2: input.KeyRightShift,
0xFFE3: input.KeyLeftControl,
0xFFE4: input.KeyRightControl,
0xFFE7: input.KeyLeftMeta,
0xFFE8: input.KeyRightMeta,
0xFFE9: input.KeyLeftAlt,
0xFFEA: input.KeyRightAlt,
0xFFEB: input.KeyLeftSuper,
0xFFEC: input.KeyRightSuper,
0xFFED: input.KeyLeftHyper,
0xFFEE: input.KeyRightHyper,
0xFFFF: input.KeyDelete,
0xFFBE: input.KeyF1,
0xFFBF: input.KeyF2,
0xFFC0: input.KeyF3,
0xFFC1: input.KeyF4,
0xFFC2: input.KeyF5,
0xFFC3: input.KeyF6,
0xFFC4: input.KeyF7,
0xFFC5: input.KeyF8,
0xFFC6: input.KeyF9,
0xFFC7: input.KeyF10,
0xFFC8: input.KeyF11,
0xFFC9: input.KeyF12,
// TODO: send this whenever a compose key, dead key, etc is pressed,
// and then send the resulting character while witholding the key
// presses that were used to compose it. As far as the program is
// concerned, a magical key with the final character was pressed and the
// KeyDead key is just so that the program might provide some visual
// feedback to the user while input is being waited for.
0xFF20: input.KeyDead,
}
var keypadCodeTable = map[xproto.Keysym] input.Key {
0xff80: input.Key(' '),
0xff89: input.KeyTab,
0xff8d: input.KeyEnter,
0xff91: input.KeyF1,
0xff92: input.KeyF2,
0xff93: input.KeyF3,
0xff94: input.KeyF4,
0xff95: input.KeyHome,
0xff96: input.KeyLeft,
0xff97: input.KeyUp,
0xff98: input.KeyRight,
0xff99: input.KeyDown,
0xff9a: input.KeyPageUp,
0xff9b: input.KeyPageDown,
0xff9c: input.KeyEnd,
0xff9d: input.KeyHome,
0xff9e: input.KeyInsert,
0xff9f: input.KeyDelete,
0xffbd: input.Key('='),
0xffaa: input.Key('*'),
0xffab: input.Key('+'),
0xffac: input.Key(','),
0xffad: input.Key('-'),
0xffae: input.Key('.'),
0xffaf: input.Key('/'),
0xffb0: input.Key('0'),
0xffb1: input.Key('1'),
0xffb2: input.Key('2'),
0xffb3: input.Key('3'),
0xffb4: input.Key('4'),
0xffb5: input.Key('5'),
0xffb6: input.Key('6'),
0xffb7: input.Key('7'),
0xffb8: input.Key('8'),
0xffb9: input.Key('9'),
}
// initializeKeymapInformation grabs keyboard mapping information from the X
// server.
func (backend *Backend) initializeKeymapInformation () {
keybind.Initialize(backend.connection)
backend.modifierMasks.capsLock = backend.keysymToMask(0xFFE5)
backend.modifierMasks.shiftLock = backend.keysymToMask(0xFFE6)
backend.modifierMasks.numLock = backend.keysymToMask(0xFF7F)
backend.modifierMasks.modeSwitch = backend.keysymToMask(0xFF7E)
backend.modifierMasks.hyper = backend.keysymToMask(0xffed)
backend.modifierMasks.super = backend.keysymToMask(0xffeb)
backend.modifierMasks.meta = backend.keysymToMask(0xffe7)
backend.modifierMasks.alt = backend.keysymToMask(0xffe9)
}
// keysymToKeycode converts an X keysym to an X keycode, instead of the other
// way around.
func (backend *Backend) keysymToKeycode (
symbol xproto.Keysym,
) (
code xproto.Keycode,
) {
mapping := keybind.KeyMapGet(backend.connection)
for index, testSymbol := range mapping.Keysyms {
if testSymbol == symbol {
code = xproto.Keycode (
index /
int(mapping.KeysymsPerKeycode) +
int(backend.connection.Setup().MinKeycode))
break
}
}
return
}
// keysymToMask returns the X modmask for a given modifier key.
func (backend *Backend) keysymToMask (
symbol xproto.Keysym,
) (
mask uint16,
) {
mask = keybind.ModGet (
backend.connection,
backend.keysymToKeycode(symbol))
return
}
// keycodeToButton converts an X keycode to a tomo keycode. It implements a more
// fleshed out version of some of the logic found in xgbutil/keybind/encoding.go
// to get a full keycode to keysym conversion, but eliminates redundant work by
// going straight to a tomo keycode.
func (backend *Backend) keycodeToKey (
keycode xproto.Keycode,
state uint16,
) (
button input.Key,
numberPad bool,
) {
// PARAGRAPH 3
//
// A list of KeySyms is associated with each KeyCode. The list is
// intended to convey the set of symbols on the corresponding key. If
// the list (ignoring trailing NoSymbol entries) is a single KeySym
// ``K'', then the list is treated as if it were the list ``K NoSymbol
// K NoSymbol''. If the list (ignoring trailing NoSymbol entries) is a
// pair of KeySyms ``K1 K2'', then the list is treated as if it were the
// list ``K1 K2 K1 K2''. If the list (ignoring trailing NoSymbol
// entries) is a triple of KeySyms ``K1 K2 K3'', then the list is
// treated as if it were the list ``K1 K2 K3 NoSymbol''. When an
// explicit ``void'' element is desired in the list, the value
// VoidSymbol can be used.
symbol1 := keybind.KeysymGet(backend.connection, keycode, 0)
symbol2 := keybind.KeysymGet(backend.connection, keycode, 1)
symbol3 := keybind.KeysymGet(backend.connection, keycode, 2)
symbol4 := keybind.KeysymGet(backend.connection, keycode, 3)
switch {
case symbol2 == 0 && symbol3 == 0 && symbol4 == 0:
symbol3 = symbol1
case symbol3 == 0 && symbol4 == 0:
symbol3 = symbol1
symbol4 = symbol2
case symbol4 == 0:
symbol4 = 0
}
symbol1Rune := keysymToRune(symbol1)
symbol2Rune := keysymToRune(symbol2)
symbol3Rune := keysymToRune(symbol3)
symbol4Rune := keysymToRune(symbol4)
// PARAGRAPH 4
//
// The first four elements of the list are split into two groups of
// KeySyms. Group 1 contains the first and second KeySyms; Group 2
// contains the third and fourth KeySyms. Within each group, if the
// second element of the group is NoSymbol , then the group should be
// treated as if the second element were the same as the first element,
// except when the first element is an alphabetic KeySym ``K'' for which
// both lowercase and uppercase forms are defined. In that case, the
// group should be treated as if the first element were the lowercase
// form of ``K'' and the second element were the uppercase form of
// ``K.''
cased := false
if symbol2 == 0 {
upper := unicode.IsUpper(symbol1Rune)
lower := unicode.IsLower(symbol1Rune)
if upper || lower {
symbol1Rune = unicode.ToLower(symbol1Rune)
symbol2Rune = unicode.ToUpper(symbol1Rune)
cased = true
} else {
symbol2 = symbol1
symbol2Rune = symbol1Rune
}
}
if symbol4 == 0 {
upper := unicode.IsUpper(symbol3Rune)
lower := unicode.IsLower(symbol3Rune)
if upper || lower {
symbol3Rune = unicode.ToLower(symbol3Rune)
symbol4Rune = unicode.ToUpper(symbol3Rune)
cased = true
} else {
symbol4 = symbol3
symbol4Rune = symbol3Rune
}
}
// PARAGRAPH 5
//
// The standard rules for obtaining a KeySym from a KeyPress event make
// use of only the Group 1 and Group 2 KeySyms; no interpretation of/
// other KeySyms in the list is given. Which group to use is determined
// by the modifier state. Switching between groups is controlled by the
// KeySym named MODE SWITCH, by attaching that KeySym to some KeyCode
// and attaching that KeyCode to any one of the modifiers Mod1 through
// Mod5. This modifier is called the group modifier. For any KeyCode,
// Group 1 is used when the group modifier is off, and Group 2 is used
// when the group modifier is on.
modeSwitch := state & backend.modifierMasks.modeSwitch > 0
if modeSwitch {
symbol1 = symbol3
symbol1Rune = symbol3Rune
symbol2 = symbol4
symbol2Rune = symbol4Rune
}
// PARAGRAPH 6
//
// The Lock modifier is interpreted as CapsLock when the KeySym named
// XK_Caps_Lock is attached to some KeyCode and that KeyCode is attached
// to the Lock modifier. The Lock modifier is interpreted as ShiftLock
// when the KeySym named XK_Shift_Lock is attached to some KeyCode and
// that KeyCode is attached to the Lock modifier. If the Lock modifier
// could be interpreted as both CapsLock and ShiftLock, the CapsLock
// interpretation is used.
shift :=
state & xproto.ModMaskShift > 0 ||
state & backend.modifierMasks.shiftLock > 0
capsLock := state & backend.modifierMasks.capsLock > 0
// PARAGRAPH 7
//
// The operation of keypad keys is controlled by the KeySym named
// XK_Num_Lock, by attaching that KeySym to some KeyCode and attaching
// that KeyCode to any one of the modifiers Mod1 through Mod5 . This
// modifier is called the numlock modifier. The standard KeySyms with
// the prefix ``XK_KP_'' in their name are called keypad KeySyms; these
// are KeySyms with numeric value in the hexadecimal range 0xFF80 to
// 0xFFBD inclusive. In addition, vendor-specific KeySyms in the
// hexadecimal range 0x11000000 to 0x1100FFFF are also keypad KeySyms.
numLock := state & backend.modifierMasks.numLock > 0
// PARAGRAPH 8
//
// Within a group, the choice of KeySym is determined by applying the
// first rule that is satisfied from the following list:
var selectedKeysym xproto.Keysym
var selectedRune rune
_, symbol2IsNumPad := keypadCodeTable[symbol2]
switch {
case numLock && symbol2IsNumPad:
// The numlock modifier is on and the second KeySym is a keypad
// KeySym. In this case, if the Shift modifier is on, or if the
// Lock modifier is on and is interpreted as ShiftLock, then the
// first KeySym is used, otherwise the second KeySym is used.
if shift {
selectedKeysym = symbol1
selectedRune = symbol1Rune
} else {
selectedKeysym = symbol2
selectedRune = symbol2Rune
}
case !shift && !capsLock:
// The Shift and Lock modifiers are both off. In this case, the
// first KeySym is used.
selectedKeysym = symbol1
selectedRune = symbol1Rune
case !shift && capsLock:
// The Shift modifier is off, and the Lock modifier is on and is
// interpreted as CapsLock. In this case, the first KeySym is
// used, but if that KeySym is lowercase alphabetic, then the
// corresponding uppercase KeySym is used instead.
if cased && unicode.IsLower(symbol1Rune) {
selectedRune = symbol2Rune
} else {
selectedKeysym = symbol1
selectedRune = symbol1Rune
}
case shift && capsLock:
// The Shift modifier is on, and the Lock modifier is on and is
// interpreted as CapsLock. In this case, the second KeySym is
// used, but if that KeySym is lowercase alphabetic, then the
// corresponding uppercase KeySym is used instead.
if cased && unicode.IsLower(symbol2Rune) {
selectedRune = unicode.ToUpper(symbol2Rune)
} else {
selectedKeysym = symbol2
selectedRune = symbol2Rune
}
case shift:
// The Shift modifier is on, or the Lock modifier is on and is
// interpreted as ShiftLock, or both. In this case, the second
// KeySym is used.
selectedKeysym = symbol2
selectedRune = symbol2Rune
}
////////////////////////////////////////////////////////////////
// all of the below stuff is specific to tomo's button codes. //
////////////////////////////////////////////////////////////////
// look up in control code table
var isControl bool
button, isControl = buttonCodeTable[selectedKeysym]
if isControl { return }
// look up in keypad table
button, numberPad = keypadCodeTable[selectedKeysym]
if numberPad { return }
// otherwise, use the rune
button = input.Key(selectedRune)
return
}
// keysymToRune takes in an X keysym and outputs a utf32 code point. This
// function does not and should not handle keypad keys, as those are handled
// by Backend.keycodeToButton.
func keysymToRune (keysym xproto.Keysym) (character rune) {
// X keysyms like 0xFF.. or 0xFE.. are non-character keys. these cannot
// be converted so we return a zero.
if (keysym >> 8) == 0xFF || (keysym >> 8) == 0xFE {
character = 0
return
}
// some X keysyms have a single bit set to 1 here. i believe this is to
// prevent conflicts with existing codes. if we mask it off we will get
// a correct utf-32 code point.
if keysym & 0xF000000 == 0x1000000 {
character = rune(keysym & 0x0111111)
return
}
// if none of these things happened, we can safely (i think) assume that
// the keysym is an exact utf-32 code point.
character = rune(keysym)
return
}

292
plugins/x/x/entity.go Normal file
View File

@@ -0,0 +1,292 @@
package x
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/artist"
type entity struct {
window *window
parent *entity
children []*entity
element tomo.Element
bounds image.Rectangle
clippedBounds image.Rectangle
minWidth int
minHeight int
selected bool
layoutInvalid bool
isContainer bool
}
func (backend *Backend) NewEntity (owner tomo.Element) tomo.Entity {
entity := &entity { element: owner }
if _, ok := owner.(tomo.Container); ok {
entity.isContainer = true
entity.InvalidateLayout()
}
return entity
}
func (ent *entity) unlink () {
ent.propagate (func (child *entity) bool {
if child.window != nil {
delete(ent.window.system.drawingInvalid, child)
}
child.window = nil
return true
})
if ent.window != nil {
delete(ent.window.system.drawingInvalid, ent)
}
ent.parent = nil
ent.window = nil
if element, ok := ent.element.(tomo.Selectable); ok {
ent.selected = false
element.HandleSelectionChange()
}
}
func (entity *entity) link (parent *entity) {
entity.parent = parent
entity.clip(parent.clippedBounds)
if parent.window != nil {
entity.setWindow(parent.window)
}
}
func (ent *entity) setWindow (window *window) {
ent.window = window
ent.Invalidate()
ent.InvalidateLayout()
ent.propagate (func (child *entity) bool {
child.window = window
ent.Invalidate()
ent.InvalidateLayout()
return true
})
}
func (entity *entity) propagate (callback func (*entity) bool) bool {
for _, child := range entity.children {
if !child.propagate(callback) {
return false
}
}
return callback(entity)
}
func (entity *entity) propagateAlt (callback func (*entity) bool) bool {
if !callback(entity) {
return false
}
for _, child := range entity.children {
if !child.propagate(callback) {
return false
}
}
return true
}
func (entity *entity) childAt (point image.Point) *entity {
for _, child := range entity.children {
if point.In(child.bounds) {
return child.childAt(point)
}
}
return entity
}
func (entity *entity) scrollTargetChildAt (point image.Point) *entity {
for _, child := range entity.children {
if point.In(child.bounds) {
result := child.scrollTargetChildAt(point)
if result != nil { return result }
break
}
}
if _, ok := entity.element.(tomo.ScrollTarget); ok {
return entity
}
return nil
}
func (entity *entity) forMouseTargetContainers (callback func (tomo.MouseTargetContainer, tomo.Element)) {
if entity.parent == nil { return }
if parent, ok := entity.parent.element.(tomo.MouseTargetContainer); ok {
callback(parent, entity.element)
}
entity.parent.forMouseTargetContainers(callback)
}
func (entity *entity) clip (bounds image.Rectangle) {
entity.clippedBounds = entity.bounds.Intersect(bounds)
for _, child := range entity.children {
child.clip(entity.clippedBounds)
}
}
// ----------- Entity ----------- //
func (entity *entity) Invalidate () {
if entity.window == nil { return }
if entity.window.system.invalidateIgnore { return }
entity.window.drawingInvalid.Add(entity)
}
func (entity *entity) Bounds () image.Rectangle {
return entity.bounds
}
func (entity *entity) Window () tomo.Window {
return entity.window
}
func (entity *entity) SetMinimumSize (width, height int) {
entity.minWidth = width
entity.minHeight = height
if entity.parent == nil {
if entity.window != nil {
entity.window.setMinimumSize(width, height)
}
} else {
entity.parent.element.(tomo.Container).
HandleChildMinimumSizeChange(entity.element)
}
}
func (entity *entity) DrawBackground (destination canvas.Canvas) {
if entity.parent != nil {
entity.parent.element.(tomo.Container).DrawBackground(destination)
} else if entity.window != nil {
entity.window.system.theme.Pattern (
tomo.PatternBackground,
tomo.State { }).Draw (
destination,
entity.window.canvas.Bounds())
}
}
// ----------- ContainerEntity ----------- //
func (entity *entity) InvalidateLayout () {
if entity.window == nil { return }
if !entity.isContainer { return }
entity.layoutInvalid = true
entity.window.system.anyLayoutInvalid = true
}
func (ent *entity) Adopt (child tomo.Element) {
childEntity, ok := child.Entity().(*entity)
if !ok || childEntity == nil { return }
childEntity.link(ent)
ent.children = append(ent.children, childEntity)
}
func (ent *entity) Insert (index int, child tomo.Element) {
childEntity, ok := child.Entity().(*entity)
if !ok || childEntity == nil { return }
ent.children = append (
ent.children[:index + 1],
ent.children[index:]...)
ent.children[index] = childEntity
}
func (entity *entity) Disown (index int) {
entity.children[index].unlink()
entity.children = append (
entity.children[:index],
entity.children[index + 1:]...)
}
func (entity *entity) IndexOf (child tomo.Element) int {
for index, childEntity := range entity.children {
if childEntity.element == child {
return index
}
}
return -1
}
func (entity *entity) Child (index int) tomo.Element {
return entity.children[index].element
}
func (entity *entity) CountChildren () int {
return len(entity.children)
}
func (entity *entity) PlaceChild (index int, bounds image.Rectangle) {
child := entity.children[index]
child.bounds = bounds
child.clip(entity.clippedBounds)
child.Invalidate()
child.InvalidateLayout()
}
func (entity *entity) SelectChild (index int, selected bool) {
child := entity.children[index]
if element, ok := child.element.(tomo.Selectable); ok {
if child.selected == selected { return }
child.selected = selected
element.HandleSelectionChange()
}
}
func (entity *entity) ChildMinimumSize (index int) (width, height int) {
childEntity := entity.children[index]
return childEntity.minWidth, childEntity.minHeight
}
// ----------- FocusableEntity ----------- //
func (entity *entity) Focused () bool {
if entity.window == nil { return false }
return entity.window.focused == entity
}
func (entity *entity) Focus () {
if entity.window == nil { return }
entity.window.system.focus(entity)
}
func (entity *entity) FocusNext () {
entity.window.system.focusNext()
}
func (entity *entity) FocusPrevious () {
entity.window.system.focusPrevious()
}
// ----------- SelectableEntity ----------- //
func (entity *entity) Selected () bool {
return entity.selected
}
// ----------- FlexibleEntity ----------- //
func (entity *entity) NotifyFlexibleHeightChange () {
if entity.parent == nil { return }
if parent, ok := entity.parent.element.(tomo.FlexibleContainer); ok {
parent.HandleChildFlexibleHeightChange (
entity.element.(tomo.Flexible))
}
}
// ----------- ScrollableEntity ----------- //
func (entity *entity) NotifyScrollBoundsChange () {
if entity.parent == nil { return }
if parent, ok := entity.parent.element.(tomo.ScrollableContainer); ok {
parent.HandleChildScrollBoundsChange (
entity.element.(tomo.Scrollable))
}
}

428
plugins/x/x/event.go Normal file
View File

@@ -0,0 +1,428 @@
package x
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "github.com/jezek/xgbutil"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/xevent"
type scrollSum struct {
x, y int
}
const scrollDistance = 16
func (sum *scrollSum) add (button xproto.Button, window *window, state uint16) {
shift :=
(state & xproto.ModMaskShift) > 0 ||
(state & window.backend.modifierMasks.shiftLock) > 0
if shift {
switch button {
case 4: sum.x -= scrollDistance
case 5: sum.x += scrollDistance
case 6: sum.y -= scrollDistance
case 7: sum.y += scrollDistance
}
} else {
switch button {
case 4: sum.y -= scrollDistance
case 5: sum.y += scrollDistance
case 6: sum.x -= scrollDistance
case 7: sum.x += scrollDistance
}
}
}
func (window *window) handleExpose (
connection *xgbutil.XUtil,
event xevent.ExposeEvent,
) {
_, region := window.compressExpose(*event.ExposeEvent)
window.pushRegion(region)
}
func (window *window) updateBounds () {
// FIXME: some window managers parent windows more than once, we might
// need to sum up all their positions.
decorGeometry, _ := window.xWindow.DecorGeometry()
windowGeometry, _ := window.xWindow.Geometry()
window.metrics.bounds = tomo.Bounds (
windowGeometry.X() + decorGeometry.X(),
windowGeometry.Y() + decorGeometry.Y(),
windowGeometry.Width(), windowGeometry.Height())
}
func (window *window) handleConfigureNotify (
connection *xgbutil.XUtil,
event xevent.ConfigureNotifyEvent,
) {
if window.child == nil { return }
configureEvent := *event.ConfigureNotifyEvent
newWidth := int(configureEvent.Width)
newHeight := int(configureEvent.Height)
sizeChanged :=
window.metrics.bounds.Dx() != newWidth ||
window.metrics.bounds.Dy() != newHeight
window.updateBounds()
if sizeChanged {
configureEvent = window.compressConfigureNotify(configureEvent)
window.reallocateCanvas()
window.resizeChildToFit()
if !window.exposeEventFollows(configureEvent) {
window.child.Invalidate()
window.child.InvalidateLayout()
}
}
}
func (window *window) exposeEventFollows (event xproto.ConfigureNotifyEvent) (found bool) {
nextEvents := xevent.Peek(window.backend.connection)
if len(nextEvents) > 0 {
untypedEvent := nextEvents[0]
if untypedEvent.Err == nil {
typedEvent, ok :=
untypedEvent.Event.(xproto.ConfigureNotifyEvent)
if ok && typedEvent.Window == event.Window {
return true
}
}
}
return false
}
func (window *window) modifiersFromState (
state uint16,
) (
modifiers input.Modifiers,
) {
return input.Modifiers {
Shift:
(state & xproto.ModMaskShift) > 0 ||
(state & window.backend.modifierMasks.shiftLock) > 0,
Control: (state & xproto.ModMaskControl) > 0,
Alt: (state & window.backend.modifierMasks.alt) > 0,
Meta: (state & window.backend.modifierMasks.meta) > 0,
Super: (state & window.backend.modifierMasks.super) > 0,
Hyper: (state & window.backend.modifierMasks.hyper) > 0,
}
}
func (window *window) handleKeyPress (
connection *xgbutil.XUtil,
event xevent.KeyPressEvent,
) {
if window.hasModal { return }
keyEvent := *event.KeyPressEvent
key, numberPad := window.backend.keycodeToKey(keyEvent.Detail, keyEvent.State)
modifiers := window.modifiersFromState(keyEvent.State)
modifiers.NumberPad = numberPad
if key == input.KeyTab && modifiers.Alt {
if modifiers.Shift {
window.system.focusPrevious()
} else {
window.system.focusNext()
}
} else if key == input.KeyEscape && window.shy {
window.Close()
} else if window.focused != nil {
focused, ok := window.focused.element.(tomo.KeyboardTarget)
if ok { focused.HandleKeyDown(key, modifiers) }
}
}
func (window *window) handleKeyRelease (
connection *xgbutil.XUtil,
event xevent.KeyReleaseEvent,
) {
if window.hasModal { return }
keyEvent := *event.KeyReleaseEvent
// do not process this event if it was generated from a key repeat
nextEvents := xevent.Peek(window.backend.connection)
if len(nextEvents) > 0 {
untypedEvent := nextEvents[0]
if untypedEvent.Err == nil {
typedEvent, ok :=
untypedEvent.Event.(xproto.KeyPressEvent)
if ok && typedEvent.Detail == keyEvent.Detail &&
typedEvent.Event == keyEvent.Event &&
typedEvent.State == keyEvent.State {
return
}
}
}
key, numberPad := window.backend.keycodeToKey(keyEvent.Detail, keyEvent.State)
modifiers := window.modifiersFromState(keyEvent.State)
modifiers.NumberPad = numberPad
if window.focused != nil {
focused, ok := window.focused.element.(tomo.KeyboardTarget)
if ok { focused.HandleKeyUp(key, modifiers) }
}
}
func (window *window) handleButtonPress (
connection *xgbutil.XUtil,
event xevent.ButtonPressEvent,
) {
if window.hasModal { return }
buttonEvent := *event.ButtonPressEvent
point := image.Pt(int(buttonEvent.EventX), int(buttonEvent.EventY))
insideWindow := point.In(window.canvas.Bounds())
scrolling := buttonEvent.Detail >= 4 && buttonEvent.Detail <= 7
modifiers := window.modifiersFromState(buttonEvent.State)
if !insideWindow && window.shy && !scrolling {
window.Close()
} else if scrolling {
underneath := window.system.scrollTargetChildAt(point)
if underneath != nil {
if child, ok := underneath.element.(tomo.ScrollTarget); ok {
sum := scrollSum { }
sum.add(buttonEvent.Detail, window, buttonEvent.State)
window.compressScrollSum(buttonEvent, &sum)
child.HandleScroll (
point, float64(sum.x), float64(sum.y),
modifiers)
}
}
} else {
underneath := window.system.childAt(point)
window.system.drags[buttonEvent.Detail] = underneath
if child, ok := underneath.element.(tomo.MouseTarget); ok {
child.HandleMouseDown (
point, input.Button(buttonEvent.Detail),
modifiers)
}
callback := func (container tomo.MouseTargetContainer, child tomo.Element) {
container.HandleChildMouseDown (
point, input.Button(buttonEvent.Detail),
modifiers, child)
}
underneath.forMouseTargetContainers(callback)
}
}
func (window *window) handleButtonRelease (
connection *xgbutil.XUtil,
event xevent.ButtonReleaseEvent,
) {
if window.hasModal { return }
buttonEvent := *event.ButtonReleaseEvent
if buttonEvent.Detail >= 4 && buttonEvent.Detail <= 7 { return }
modifiers := window.modifiersFromState(buttonEvent.State)
dragging := window.system.drags[buttonEvent.Detail]
if dragging != nil {
if child, ok := dragging.element.(tomo.MouseTarget); ok {
child.HandleMouseUp (
image.Pt (
int(buttonEvent.EventX),
int(buttonEvent.EventY)),
input.Button(buttonEvent.Detail),
modifiers)
}
callback := func (container tomo.MouseTargetContainer, child tomo.Element) {
container.HandleChildMouseUp (
image.Pt (
int(buttonEvent.EventX),
int(buttonEvent.EventY)),
input.Button(buttonEvent.Detail),
modifiers, child)
}
dragging.forMouseTargetContainers(callback)
}
}
func (window *window) handleMotionNotify (
connection *xgbutil.XUtil,
event xevent.MotionNotifyEvent,
) {
if window.hasModal { return }
motionEvent := window.compressMotionNotify(*event.MotionNotifyEvent)
x := int(motionEvent.EventX)
y :=int(motionEvent.EventY)
handled := false
for _, child := range window.system.drags {
if child == nil { continue }
if child, ok := child.element.(tomo.MotionTarget); ok {
child.HandleMotion(image.Pt(x, y))
handled = true
}
}
if !handled {
child := window.system.childAt(image.Pt(x, y))
if child, ok := child.element.(tomo.MotionTarget); ok {
child.HandleMotion(image.Pt(x, y))
}
}
}
func (window *window) handleSelectionNotify (
connection *xgbutil.XUtil,
event xevent.SelectionNotifyEvent,
) {
if window.selectionRequest == nil { return }
window.selectionRequest.handleSelectionNotify(connection, event)
if !window.selectionRequest.open() { window.selectionRequest = nil }
}
func (window *window) handlePropertyNotify (
connection *xgbutil.XUtil,
event xevent.PropertyNotifyEvent,
) {
if window.selectionRequest == nil { return }
window.selectionRequest.handlePropertyNotify(connection, event)
if !window.selectionRequest.open() { window.selectionRequest = nil }
}
func (window *window) handleSelectionClear (
connection *xgbutil.XUtil,
event xevent.SelectionClearEvent,
) {
window.selectionClaim = nil
}
func (window *window) handleSelectionRequest (
connection *xgbutil.XUtil,
event xevent.SelectionRequestEvent,
) {
if window.selectionClaim == nil { return }
window.selectionClaim.handleSelectionRequest(connection, event)
}
func (window *window) compressExpose (
firstEvent xproto.ExposeEvent,
) (
lastEvent xproto.ExposeEvent,
region image.Rectangle,
) {
region = image.Rect (
int(firstEvent.X), int(firstEvent.Y),
int(firstEvent.X + firstEvent.Width),
int(firstEvent.Y + firstEvent.Height))
window.backend.connection.Sync()
xevent.Read(window.backend.connection, false)
lastEvent = firstEvent
for index, untypedEvent := range xevent.Peek(window.backend.connection) {
if untypedEvent.Err != nil { continue }
typedEvent, ok := untypedEvent.Event.(xproto.ExposeEvent)
if !ok { continue }
if firstEvent.Window == typedEvent.Window {
region = region.Union (image.Rect (
int(typedEvent.X), int(typedEvent.Y),
int(typedEvent.X + typedEvent.Width),
int(typedEvent.Y + typedEvent.Height)))
lastEvent = typedEvent
defer func (index int) {
xevent.DequeueAt(window.backend.connection, index)
} (index)
}
}
return
}
func (window *window) compressConfigureNotify (
firstEvent xproto.ConfigureNotifyEvent,
) (
lastEvent xproto.ConfigureNotifyEvent,
) {
window.backend.connection.Sync()
xevent.Read(window.backend.connection, false)
lastEvent = firstEvent
for index, untypedEvent := range xevent.Peek(window.backend.connection) {
if untypedEvent.Err != nil { continue }
typedEvent, ok := untypedEvent.Event.(xproto.ConfigureNotifyEvent)
if !ok { continue }
if firstEvent.Event == typedEvent.Event &&
firstEvent.Window == typedEvent.Window {
lastEvent = typedEvent
defer func (index int) {
xevent.DequeueAt(window.backend.connection, index)
} (index)
}
}
return
}
func (window *window) compressScrollSum (
firstEvent xproto.ButtonPressEvent,
sum *scrollSum,
) {
window.backend.connection.Sync()
xevent.Read(window.backend.connection, false)
for index, untypedEvent := range xevent.Peek(window.backend.connection) {
if untypedEvent.Err != nil { continue }
typedEvent, ok := untypedEvent.Event.(xproto.ButtonPressEvent)
if !ok { continue }
if firstEvent.Event == typedEvent.Event &&
typedEvent.Detail >= 4 &&
typedEvent.Detail <= 7 {
sum.add(typedEvent.Detail, window, typedEvent.State)
defer func (index int) {
xevent.DequeueAt(window.backend.connection, index)
} (index)
}
}
return
}
func (window *window) compressMotionNotify (
firstEvent xproto.MotionNotifyEvent,
) (
lastEvent xproto.MotionNotifyEvent,
) {
window.backend.connection.Sync()
xevent.Read(window.backend.connection, false)
lastEvent = firstEvent
for index, untypedEvent := range xevent.Peek(window.backend.connection) {
if untypedEvent.Err != nil { continue }
typedEvent, ok := untypedEvent.Event.(xproto.MotionNotifyEvent)
if !ok { continue }
if firstEvent.Event == typedEvent.Event {
lastEvent = typedEvent
defer func (index int) {
xevent.DequeueAt(window.backend.connection, index)
} (index)
}
}
return
}

365
plugins/x/x/selection.go Normal file
View File

@@ -0,0 +1,365 @@
package x
import "errors"
import "strings"
import "github.com/jezek/xgb"
import "github.com/jezek/xgbutil"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/xprop"
import "github.com/jezek/xgbutil/xevent"
import "git.tebibyte.media/sashakoshka/tomo/data"
const clipboardName = "CLIPBOARD"
type selReqState int; const (
selReqStateClosed selReqState = iota
selReqStateAwaitTargets
selReqStateAwaitValue
selReqStateAwaitChunk
)
type selectionRequest struct {
state selReqState
window *window
source xproto.Atom
destination xproto.Atom
accept []data.Mime
incrBuffer []byte
incrMime data.Mime
callback func (data.Data, error)
}
func (window *window) newSelectionRequest (
source, destination xproto.Atom,
callback func (data.Data, error),
accept ...data.Mime,
) (
request *selectionRequest,
) {
request = &selectionRequest {
source: source,
destination: destination,
window: window,
accept: accept,
callback: callback,
}
targets, err := xprop.Atm(window.backend.connection, "TARGETS")
if err != nil { request.die(err); return }
request.convertSelection(targets, selReqStateAwaitTargets)
return
}
func (request *selectionRequest) convertSelection (
target xproto.Atom, switchTo selReqState,
) {
// The requestor should set the property argument to the name of a
// property that the owner can use to report the value of the selection.
// Requestors should ensure that the named property does not exist on
// the window before issuing the ConvertSelection. The exception to this
// rule is when the requestor intends to pass parameters with the
// request. Some targets may be defined such that requestors can pass
// parameters along with the request. If the requestor wishes to provide
// parameters to a request, they should be placed in the specified
// property on the requestor window before the requestor issues the
// ConvertSelection request, and this property should be named in the
// request.
err := xproto.DeletePropertyChecked (
request.window.backend.connection.Conn(),
request.window.xWindow.Id,
request.destination).Check()
if err != nil { request.die(err); return }
// The selection argument specifies the particular selection involved,
// and the target argument specifies the required form of the
// information. For information about the choice of suitable atoms to
// use, see section 2.6. The requestor should set the requestor argument
// to a window that it created; the owner will place the reply property
// there. The requestor should set the time argument to the timestamp on
// the event that triggered the request for the selection value. Note
// that clients should not specify CurrentTime*.
err = xproto.ConvertSelectionChecked (
request.window.backend.connection.Conn(),
request.window.xWindow.Id,
request.source,
target,
request.destination,
// TODO: *possibly replace this zero with an actual timestamp
// received from the server. this is non-trivial as we cannot
// rely on the timestamp of the last received event, because
// there is a possibility that this method is invoked
// asynchronously from within tomo.Do().
0).Check()
if err != nil { request.die(err); return }
request.state = switchTo
}
func (request *selectionRequest) die (err error) {
request.callback(nil, err)
request.window.system.afterEvent()
request.state = selReqStateClosed
}
func (request *selectionRequest) finalize (data data.Data) {
request.callback(data, nil)
request.window.system.afterEvent()
request.state = selReqStateClosed
}
func (request *selectionRequest) open () bool {
return request.state != selReqStateClosed
}
type confidence int; const (
confidenceNone confidence = iota
confidencePartial
confidenceFull
)
func targetToMime (name string) (data.Mime, confidence) {
// TODO: add other stuff. reference this table:
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2
// perhaps we should also have parameters for mime types so we can
// return an encoding here for things like STRING?
switch name {
case "ADOBE_PORTABLE_DOCUMENT_FORMAT":
return data.M("application", "pdf"), confidenceFull
case "APPLE_PICT":
return data.M("image", "pict"), confidenceFull
case
"POSTSCRIPT",
"ENCAPSULATED_POSTSCRIPT",
"ENCAPSULATED_POSTSCRIPT_INTERCHANGE":
return data.M("application", "postscript"), confidenceFull
case "FILE_NAME":
return data.MimeFile, confidenceFull
case "UTF8_STRING":
return data.MimePlain, confidenceFull
case "TEXT":
return data.MimePlain, confidencePartial
case "STRING":
return data.MimePlain, confidencePartial
default:
if strings.Count(name, "/") == 1 {
ty, subtype, _ := strings.Cut(name, "/")
return data.M(ty, subtype), confidenceFull
} else {
return data.Mime { }, confidenceNone
}
}
}
func mimeToTargets (mime data.Mime) (names []string) {
names = append(names, mime.String())
switch mime {
case data.M("application", "pdf"):
names = append(names, "ADOBE_PORTABLE_DOCUMENT_FORMAT")
case data.M("image", "pict"):
names = append(names, "APPLE_PICT")
case data.M("application", "postscript"):
names = append(names, "POSTSCRIPT")
case data.MimeFile:
names = append(names, "FILE_NAME")
case data.MimePlain:
names = append(names, "UTF8_STRING", "TEXT", "STRING")
}
return
}
func (request *selectionRequest) handleSelectionNotify (
connection *xgbutil.XUtil,
event xevent.SelectionNotifyEvent,
) {
// the only valid states that we can process a SelectionNotify event in
invalidState :=
request.state != selReqStateAwaitValue &&
request.state != selReqStateAwaitTargets
if invalidState { return }
// Follow:
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.4
// If the property argument is None, the conversion has been refused.
// This can mean either that there is no owner for the selection, that
// the owner does not support the conversion implied by the target, or
// that the server did not have sufficient space to accommodate the
// data.
if event.Property == 0 { request.die(nil); return }
// When using GetProperty to retrieve the value of a selection, the
// property argument should be set to the corresponding value in the
// SelectionNotify event. Because the requestor has no way of knowing
// beforehand what type the selection owner will use, the type argument
// should be set to AnyPropertyType. Several GetProperty requests may be
// needed to retrieve all the data in the selection; each should set the
// long-offset argument to the amount of data received so far, and the
// size argument to some reasonable buffer size (see section 2.5). If
// the returned value of bytes-after is zero, the whole property has
// been transferred.
reply, err := xproto.GetProperty (
connection.Conn(), false, request.window.xWindow.Id,
event.Property, xproto.GetPropertyTypeAny,
0, (1 << 32) - 1).Reply()
if err != nil { request.die(err); return }
if reply.Format == 0 {
request.die(errors.New("x: missing selection property"))
return
}
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.7.2
// Requestors may receive a property of type INCR9 in response to any
// target that results in selection data. This indicates that the owner
// will send the actual data incrementally. The contents of the INCR
// property will be an integer, which represents a lower bound on the
// number of bytes of data in the selection. The requestor and the
// selection owner transfer the data in the selection in the following
// manner. The selection requestor starts the transfer process by
// deleting the (type==INCR) property forming the reply to the
// selection.
incr, err := xprop.Atm(request.window.backend.connection, "INCR")
if err != nil { request.die(err); return }
if reply.Type == incr {
// reply to the INCR selection
err = xproto.DeletePropertyChecked (
request.window.backend.connection.Conn(),
request.window.xWindow.Id,
request.destination).Check()
if err != nil { request.die(err); return }
// await the first chunk
request.state = selReqStateAwaitChunk
return
}
// Once all the data in the selection has been retrieved (which may
// require getting the values of several properties &emdash; see section
// 2.7), the requestor should delete the property in the SelectionNotify
// request by using a GetProperty request with the delete argument set
// to True. As previously discussed, the owner has no way of knowing
// when the data has been transferred to the requestor unless the
// property is removed.
err = xproto.DeletePropertyChecked (
request.window.backend.connection.Conn(),
request.window.xWindow.Id,
request.destination).Check()
if err != nil { request.die(err); return }
// depending on which state the selection request is in, do something
// different with the property's value
switch request.state {
case selReqStateAwaitValue:
// get the type from the property and convert that to the mime
// value to pass to the application.
targetName, err := xprop.AtomName (
request.window.backend.connection, reply.Type)
if err != nil { request.die(err); return }
mime, _ := targetToMime(targetName)
// we now have the full selection data in the property, so we
// finalize the request and are done.
request.finalize(data.Bytes(mime, reply.Value))
case selReqStateAwaitTargets:
// make a list of the atoms we got
buffer := reply.Value
atoms := make([]xproto.Atom, len(buffer) / 4)
for index := range atoms {
atoms[index] = xproto.Atom(xgb.Get32(buffer[index * 4:]))
}
// choose the best match out of all targets using a confidence
// system
confidentMatchFound := false
var chosenTarget xproto.Atom
for _, atom := range atoms {
targetName, err := xprop.AtomName (
request.window.backend.connection, atom)
if err != nil { request.die(err); return }
mime, confidence := targetToMime(targetName)
if confidence == confidenceNone { continue }
// if the accepted types list is nil, just choose this
// one. however, if we are not 100% confident that this
// target can be directly converted into a mime type,
// don't mark it as the final match. we still want the
// mime type we give to the application to be as
// accurate as possible.
if request.accept == nil {
chosenTarget = atom
if confidence == confidenceFull {
confidentMatchFound = true
}
}
// run through the accepted types list if it exists,
// looking for a match. if one is found, then choose
// this target. however, if we are not 100% confident
// that this target directly corresponds to the mime
// type, don't mark it as the final match, because there
// may be a better target in the list.
for _, accept := range request.accept {
if accept == mime {
chosenTarget = atom
if confidence == confidenceFull {
confidentMatchFound = true
}
break
}}
if confidentMatchFound { break }
}
// if we didn't find a match, finalize the request with an empty
// data map to inform the application that, although there were
// no errors, there wasn't a suitable target to choose from.
if chosenTarget == 0 {
request.finalize(data.Data { })
return
}
// await the selection value
request.convertSelection(chosenTarget, selReqStateAwaitValue)
}
}
func (request *selectionRequest) handlePropertyNotify (
connection *xgbutil.XUtil,
event xevent.PropertyNotifyEvent,
) {
// the only valid state that we can process a PropertyNotify event in
if request.state != selReqStateAwaitChunk { return }
if event.State != xproto.PropertyNewValue { return }
request.handleINCRProperty(event.Atom)
}
func (request *selectionRequest) handleINCRProperty (property xproto.Atom) {
// Retrieving data using GetProperty with the delete argument True.
reply, err := xproto.GetProperty (
request.window.backend.connection.Conn(), true,
request.window.xWindow.Id, property, xproto.GetPropertyTypeAny,
0, (1 << 32) - 1).Reply()
if err != nil { request.die(err); return }
if len(reply.Value) == 0 {
// a zero length property means the transfer has finished. we
// finalize the request with the data we have, and don't wait
// for more.
request.finalize(data.Bytes(request.incrMime, request.incrBuffer))
// we want to be extra sure we aren't holding onto this memory
request.incrBuffer = nil
} else {
// a property with content means the transfer is still ongoing.
// we append the data we got and wait for more.
request.state = selReqStateAwaitChunk
request.incrBuffer = append(request.incrBuffer, reply.Value...)
targetName, err := xprop.AtomName (
request.window.backend.connection, reply.Type)
if err != nil { request.die(err); return }
request.incrMime, _ = targetToMime(targetName)
}
}

View File

@@ -0,0 +1,164 @@
package x
import "io"
import "github.com/jezek/xgb"
import "github.com/jezek/xgbutil"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/xprop"
import "github.com/jezek/xgbutil/xevent"
import "git.tebibyte.media/sashakoshka/tomo/data"
type selectionClaim struct {
window *window
data data.Data
name xproto.Atom
}
func (window *window) claimSelection (name xproto.Atom, data data.Data) *selectionClaim {
// Follow:
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.1
// A client wishing to acquire ownership of a particular selection
// should call SetSelectionOwner. The client should set the specified
// selection to the atom that represents the selection, set the
// specified owner to some window that the client created, and set the
// specified time to some time between the current last-change time of
// the selection concerned and the current server time. This time value
// usually will be obtained from the timestamp of the event that
// triggers the acquisition of the selection. Clients should not set the
// time value to CurrentTime, because if they do so, they have no way of
// finding when they gained ownership of the selection. Clients must use
// a window they created so that requestors can route events to the
// owner of the selection.
err := xproto.SetSelectionOwnerChecked (
window.backend.connection.Conn(),
window.xWindow.Id, name, 0).Check() // FIXME: should not be zero
if err != nil { return nil }
ownerReply, err := xproto.GetSelectionOwner (
window.backend.connection.Conn(), name).Reply()
if err != nil { return nil }
if ownerReply.Owner != window.xWindow.Id { return nil }
return &selectionClaim {
window: window,
data: data,
name: name,
}
}
func (window *window) refuseSelectionRequest (request xevent.SelectionRequestEvent) {
// ... refuse the SelectionRequest by sending the requestor window a
// SelectionNotify event with the property set to None (by means of a
// SendEvent request with an empty event mask).
event := xproto.SelectionNotifyEvent {
Requestor: request.Requestor,
Selection: request.Selection,
Target: request.Target,
Property: 0,
}.Bytes()
xproto.SendEvent (
window.backend.connection.Conn(),
false, request.Requestor, 0, string(event))
}
func (window *window) fulfillSelectionRequest (
data []byte,
format byte,
ty xproto.Atom,
request xevent.SelectionRequestEvent,
) {
die := func () { window.refuseSelectionRequest(request) }
// If the specified property is not None, the owner should place the
// data resulting from converting the selection into the specified
// property on the requestor window and should set the property's type
// to some appropriate value, which need not be the same as the
// specified target.
err := xproto.ChangePropertyChecked (
window.backend.connection.Conn(),
xproto.PropModeReplace, request.Requestor,
request.Property,
ty, format,
uint32(len(data) / (int(format) / 8)), data).Check()
if err != nil { die() }
// If the property is successfully stored, the owner should acknowledge
// the successful conversion by sending the requestor window a
// SelectionNotify event (by means of a SendEvent request with an empty
// mask).
event := xproto.SelectionNotifyEvent {
Requestor: request.Requestor,
Selection: request.Selection,
Target: request.Target,
Property: request.Property,
}.Bytes()
xproto.SendEvent (
window.backend.connection.Conn(),
false, request.Requestor, 0, string(event))
}
func (claim *selectionClaim) handleSelectionRequest (
connection *xgbutil.XUtil,
event xevent.SelectionRequestEvent,
) {
// Follow:
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.2
die := func () { claim.window.refuseSelectionRequest(event) }
// When a requestor wants the value of a selection, the owner receives a
// SelectionRequest event. The specified owner and selection will be the
// values that were specified in the SetSelectionOwner request. The
// owner should compare the timestamp with the period it has owned the
// selection and, if the time is outside, refuse the SelectionRequest.
if event.Selection != claim.name { die(); return }
// If the specified property is None , the requestor is an obsolete
// client. Owners are encouraged to support these clients by using the
// specified target atom as the property name to be used for the reply.
if event.Property == 0 {
event.Property = event.Target
}
// Otherwise, the owner should use the target to decide the form into
// which the selection should be converted. Some targets may be defined
// such that requestors can pass parameters along with the request. The
// owner will find these parameters in the property named in the
// selection request. The type, format, and contents of this property
// are dependent upon the definition of the target. If the target is not
// defined to have parameters, the owner should ignore the property if
// it is present. If the selection cannot be converted into a form based
// on the target (and parameters, if any), the owner should refuse the
// SelectionRequest as previously described.
targetName, err := xprop.AtomName (
claim.window.backend.connection, event.Target)
if err != nil { die(); return }
switch targetName {
case "TARGETS":
targetNames := []string { "TARGETS", }
for mime := range claim.data {
targetNames = append(targetNames, mimeToTargets(mime)...)
}
data := make([]byte, len(targetNames) * 4)
for index, name := range targetNames {
atom, err := xprop.Atm(claim.window.backend.connection, name)
if err != nil { die(); return }
xgb.Put32(data[(index) * 4:], uint32(atom))
}
atomAtom, err := xprop.Atm(claim.window.backend.connection, "ATOM")
if err != nil { die(); return }
claim.window.fulfillSelectionRequest(data, 32, atomAtom, event)
default:
mime, confidence := targetToMime(targetName)
if confidence == confidenceNone { die(); return }
reader, ok := claim.data[mime]
if !ok { die(); return }
reader.Seek(0, io.SeekStart)
data, err := io.ReadAll(reader)
if err != nil { die() }
claim.window.fulfillSelectionRequest(data, 8, event.Target, event)
}
}

191
plugins/x/x/system.go Normal file
View File

@@ -0,0 +1,191 @@
package x
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
type entitySet map[*entity] struct { }
func (set entitySet) Empty () bool {
return len(set) == 0
}
func (set entitySet) Has (entity *entity) bool {
_, ok := set[entity]
return ok
}
func (set entitySet) Add (entity *entity) {
set[entity] = struct { } { }
}
type system struct {
child *entity
focused *entity
canvas canvas.BasicCanvas
theme theme.Wrapped
config config.Wrapped
invalidateIgnore bool
drawingInvalid entitySet
anyLayoutInvalid bool
drags [10]*entity
pushFunc func (image.Rectangle)
}
func (system *system) initialize () {
system.drawingInvalid = make(entitySet)
}
func (system *system) SetTheme (theme tomo.Theme) {
system.theme.Theme = theme
system.propagate (func (entity *entity) bool {
if child, ok := system.child.element.(tomo.Themeable); ok {
child.SetTheme(theme)
}
return true
})
}
func (system *system) SetConfig (config tomo.Config) {
system.config.Config = config
system.propagate (func (entity *entity) bool {
if child, ok := system.child.element.(tomo.Configurable); ok {
child.SetConfig(config)
}
return true
})
}
func (system *system) focus (entity *entity) {
previous := system.focused
system.focused = entity
if previous != nil {
previous.element.(tomo.Focusable).HandleFocusChange()
}
if entity != nil {
entity.element.(tomo.Focusable).HandleFocusChange()
}
}
func (system *system) focusNext () {
found := system.focused == nil
focused := false
system.propagateAlt (func (entity *entity) bool {
if found {
// looking for the next element to select
child, ok := entity.element.(tomo.Focusable)
if ok && child.Enabled() {
// found it
entity.Focus()
focused = true
return false
}
} else {
// looking for the current focused element
if entity == system.focused {
// found it
found = true
}
}
return true
})
if !focused { system.focus(nil) }
}
func (system *system) focusPrevious () {
var behind *entity
system.propagate (func (entity *entity) bool {
if entity == system.focused {
return false
}
child, ok := entity.element.(tomo.Focusable)
if ok && child.Enabled() { behind = entity }
return true
})
system.focus(behind)
}
func (system *system) propagate (callback func (*entity) bool) {
if system.child == nil { return }
system.child.propagate(callback)
}
func (system *system) propagateAlt (callback func (*entity) bool) {
if system.child == nil { return }
system.child.propagateAlt(callback)
}
func (system *system) childAt (point image.Point) *entity {
if system.child == nil { return nil }
return system.child.childAt(point)
}
func (system *system) scrollTargetChildAt (point image.Point) *entity {
if system.child == nil { return nil }
return system.child.scrollTargetChildAt(point)
}
func (system *system) resizeChildToFit () {
system.child.bounds = system.canvas.Bounds()
system.child.clippedBounds = system.child.bounds
system.child.Invalidate()
if system.child.isContainer {
system.child.InvalidateLayout()
}
}
func (system *system) afterEvent () {
if system.anyLayoutInvalid {
system.layout(system.child, false)
system.anyLayoutInvalid = false
}
system.draw()
}
func (system *system) layout (entity *entity, force bool) {
if entity == nil { return }
if entity.layoutInvalid == true || force {
if element, ok := entity.element.(tomo.Layoutable); ok {
element.Layout()
entity.layoutInvalid = false
force = true
}
}
for _, child := range entity.children {
system.layout(child, force)
}
}
func (system *system) draw () {
finalBounds := image.Rectangle { }
// ignore invalidations that result from drawing elements, because if an
// element decides to do that it really needs to rethink its life
// choices.
system.invalidateIgnore = true
defer func () { system.invalidateIgnore = false } ()
for entity := range system.drawingInvalid {
if entity.clippedBounds.Empty() { continue }
entity.element.Draw (canvas.Cut (
system.canvas,
entity.clippedBounds))
finalBounds = finalBounds.Union(entity.clippedBounds)
}
system.drawingInvalid = make(entitySet)
// TODO: don't just union all the bounds together, we can definetly
// consolidateupdated regions more efficiently than this.
if !finalBounds.Empty() {
system.pushFunc(finalBounds)
}
}

473
plugins/x/x/window.go Normal file
View File

@@ -0,0 +1,473 @@
package x
import "image"
import "errors"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/ewmh"
import "github.com/jezek/xgbutil/icccm"
import "github.com/jezek/xgbutil/xprop"
import "github.com/jezek/xgbutil/xevent"
import "github.com/jezek/xgbutil/xwindow"
import "github.com/jezek/xgbutil/keybind"
import "github.com/jezek/xgbutil/mousebind"
import "github.com/jezek/xgbutil/xgraphics"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/data"
import "git.tebibyte.media/sashakoshka/tomo/artist"
type mainWindow struct { *window }
type menuWindow struct { *window }
type window struct {
system
backend *Backend
xWindow *xwindow.Window
xCanvas *xgraphics.Image
title, application string
modalParent *window
hasModal bool
shy bool
selectionRequest *selectionRequest
selectionClaim *selectionClaim
metrics struct {
bounds image.Rectangle
}
onClose func ()
}
func (backend *Backend) NewWindow (
bounds image.Rectangle,
) (
output tomo.MainWindow,
err error,
) {
if backend == nil { panic("nil backend") }
window, err := backend.newWindow(bounds, false)
output = mainWindow { window }
return output, err
}
func (backend *Backend) newWindow (
bounds image.Rectangle,
override bool,
) (
output *window,
err error,
) {
if bounds.Dx() == 0 { bounds.Max.X = bounds.Min.X + 8 }
if bounds.Dy() == 0 { bounds.Max.Y = bounds.Min.Y + 8 }
window := &window { backend: backend }
window.system.initialize()
window.system.pushFunc = window.pasteAndPush
window.theme.Case = tomo.C("tomo", "window")
window.xWindow, err = xwindow.Generate(backend.connection)
if err != nil { return }
if override {
err = window.xWindow.CreateChecked (
backend.connection.RootWin(),
bounds.Min.X, bounds.Min.Y, bounds.Dx(), bounds.Dy(),
xproto.CwOverrideRedirect, 1)
} else {
err = window.xWindow.CreateChecked (
backend.connection.RootWin(),
bounds.Min.X, bounds.Min.Y, bounds.Dx(), bounds.Dy(), 0)
}
if err != nil { return }
err = window.xWindow.Listen (
xproto.EventMaskExposure,
xproto.EventMaskStructureNotify,
xproto.EventMaskPropertyChange,
xproto.EventMaskPointerMotion,
xproto.EventMaskKeyPress,
xproto.EventMaskKeyRelease,
xproto.EventMaskButtonPress,
xproto.EventMaskButtonRelease)
if err != nil { return }
window.xWindow.WMGracefulClose (func (xWindow *xwindow.Window) {
window.Close()
})
xevent.ExposeFun(window.handleExpose).
Connect(backend.connection, window.xWindow.Id)
xevent.ConfigureNotifyFun(window.handleConfigureNotify).
Connect(backend.connection, window.xWindow.Id)
xevent.KeyPressFun(window.handleKeyPress).
Connect(backend.connection, window.xWindow.Id)
xevent.KeyReleaseFun(window.handleKeyRelease).
Connect(backend.connection, window.xWindow.Id)
xevent.ButtonPressFun(window.handleButtonPress).
Connect(backend.connection, window.xWindow.Id)
xevent.ButtonReleaseFun(window.handleButtonRelease).
Connect(backend.connection, window.xWindow.Id)
xevent.MotionNotifyFun(window.handleMotionNotify).
Connect(backend.connection, window.xWindow.Id)
xevent.SelectionNotifyFun(window.handleSelectionNotify).
Connect(backend.connection, window.xWindow.Id)
xevent.PropertyNotifyFun(window.handlePropertyNotify).
Connect(backend.connection, window.xWindow.Id)
xevent.SelectionClearFun(window.handleSelectionClear).
Connect(backend.connection, window.xWindow.Id)
xevent.SelectionRequestFun(window.handleSelectionRequest).
Connect(backend.connection, window.xWindow.Id)
window.SetTheme(backend.theme)
window.SetConfig(backend.config)
window.metrics.bounds = bounds
window.setMinimumSize(8, 8)
window.reallocateCanvas()
backend.windows[window.xWindow.Id] = window
output = window
return
}
func (window *window) Window () tomo.Window {
return window
}
func (window *window) Adopt (child tomo.Element) {
// disown previous child
if window.child != nil {
window.child.unlink()
window.child = nil
}
// adopt new child
if child != nil {
childEntity, ok := child.Entity().(*entity)
if ok && childEntity != nil {
window.child = childEntity
childEntity.setWindow(window)
window.setMinimumSize (
childEntity.minWidth,
childEntity.minHeight)
window.resizeChildToFit()
}
}
}
func (window *window) SetTitle (title string) {
window.title = title
ewmh.WmNameSet (
window.backend.connection,
window.xWindow.Id,
title)
icccm.WmNameSet (
window.backend.connection,
window.xWindow.Id,
title)
icccm.WmIconNameSet (
window.backend.connection,
window.xWindow.Id,
title)
}
func (window *window) SetApplicationName (name string) {
window.application = name
icccm.WmClassSet (
window.backend.connection,
window.xWindow.Id,
&icccm.WmClass {
Instance: name,
Class: name,
})
}
func (window *window) SetIcon (sizes []image.Image) {
wmIcons := []ewmh.WmIcon { }
for _, icon := range sizes {
width := icon.Bounds().Max.X
height := icon.Bounds().Max.Y
wmIcon := ewmh.WmIcon {
Width: uint(width),
Height: uint(height),
Data: make ([]uint, width * height),
}
// manually convert image data beacuse of course we have to do
// this
index := 0
for y := 0; y < height; y ++ {
for x := 0; x < width; x ++ {
r, g, b, a := icon.At(x, y).RGBA()
r >>= 8
g >>= 8
b >>= 8
a >>= 8
wmIcon.Data[index] =
(uint(a) << 24) |
(uint(r) << 16) |
(uint(g) << 8) |
(uint(b) << 0)
index ++
}}
wmIcons = append(wmIcons, wmIcon)
}
ewmh.WmIconSet (
window.backend.connection,
window.xWindow.Id,
wmIcons)
}
func (window *window) NewModal (bounds image.Rectangle) (tomo.Window, error) {
modal, err := window.backend.newWindow (
bounds.Add(window.metrics.bounds.Min), false)
icccm.WmTransientForSet (
window.backend.connection,
modal.xWindow.Id,
window.xWindow.Id)
ewmh.WmStateSet (
window.backend.connection,
modal.xWindow.Id,
[]string { "_NET_WM_STATE_MODAL" })
modal.modalParent = window
window.hasModal = true
modal.inheritProperties(window)
return modal, err
}
func (window *window) NewMenu (bounds image.Rectangle) (tomo.MenuWindow, error) {
menu, err := window.backend.newWindow (
bounds.Add(window.metrics.bounds.Min), true)
menu.shy = true
icccm.WmTransientForSet (
window.backend.connection,
menu.xWindow.Id,
window.xWindow.Id)
menu.setType("POPUP_MENU")
menu.inheritProperties(window)
return menuWindow { window: menu }, err
}
func (window mainWindow) NewPanel (bounds image.Rectangle) (tomo.Window, error) {
panel, err := window.backend.newWindow (
bounds.Add(window.metrics.bounds.Min), false)
if err != nil { return nil, err }
panel.setClientLeader(window.window)
window.setClientLeader(window.window)
icccm.WmTransientForSet (
window.backend.connection,
panel.xWindow.Id,
window.xWindow.Id)
panel.setType("UTILITY")
panel.inheritProperties(window.window)
return panel, err
}
func (window menuWindow) Pin () {
// TODO take off override redirect
// TODO turn off shy
// TODO set window type to MENU
// TODO iungrab keyboard and mouse
}
func (window *window) Show () {
if window.child == nil {
window.xCanvas.For (func (x, y int) xgraphics.BGRA {
return xgraphics.BGRA { }
})
window.pushRegion(window.xCanvas.Bounds())
}
window.xWindow.Map()
if window.shy { window.grabInput() }
}
func (window *window) Hide () {
window.xWindow.Unmap()
if window.shy { window.ungrabInput() }
}
func (window *window) Copy (data data.Data) {
selectionAtom, err := xprop.Atm(window.backend.connection, clipboardName)
if err != nil { return }
window.selectionClaim = window.claimSelection(selectionAtom, data)
}
func (window *window) Paste (callback func (data.Data, error), accept ...data.Mime) {
// Follow:
// https://tronche.com/gui/x/icccm/sec-2.html#s-2.4
die := func (err error) { callback(nil, err) }
if window.selectionRequest != nil {
// TODO: add the request to a queue and take care of it when the
// current selection has completed
die(errors.New("there is already a selection request"))
return
}
propertyName := "TOMO_SELECTION"
selectionAtom, err := xprop.Atm(window.backend.connection, clipboardName)
if err != nil { die(err); return }
propertyAtom, err := xprop.Atm(window.backend.connection, propertyName)
if err != nil { die(err); return }
window.selectionRequest = window.newSelectionRequest (
selectionAtom, propertyAtom, callback, accept...)
if !window.selectionRequest.open() { window.selectionRequest = nil }
return
}
func (window *window) Close () {
if window.onClose != nil { window.onClose() }
if window.modalParent != nil {
// we are a modal dialog, so unlock the parent
window.modalParent.hasModal = false
}
window.Hide()
window.Adopt(nil)
delete(window.backend.windows, window.xWindow.Id)
window.xWindow.Destroy()
}
func (window *window) OnClose (callback func ()) {
window.onClose = callback
}
func (window *window) grabInput () {
keybind.GrabKeyboard(window.backend.connection, window.xWindow.Id)
mousebind.GrabPointer (
window.backend.connection,
window.xWindow.Id,
window.backend.connection.RootWin(), 0)
}
func (window *window) ungrabInput () {
keybind.UngrabKeyboard(window.backend.connection)
mousebind.UngrabPointer(window.backend.connection)
}
func (window *window) inheritProperties (parent *window) {
window.SetApplicationName(parent.application)
}
func (window *window) setType (ty string) error {
return ewmh.WmWindowTypeSet (
window.backend.connection,
window.xWindow.Id,
[]string { "_NET_WM_WINDOW_TYPE_" + ty })
}
func (window *window) setClientLeader (leader *window) error {
hints, _ := icccm.WmHintsGet(window.backend.connection, window.xWindow.Id)
if hints == nil {
hints = &icccm.Hints { }
}
hints.Flags |= icccm.HintWindowGroup
hints.WindowGroup = leader.xWindow.Id
return icccm.WmHintsSet (
window.backend.connection,
window.xWindow.Id,
hints)
}
func (window *window) reallocateCanvas () {
window.canvas.Reallocate (
window.metrics.bounds.Dx(),
window.metrics.bounds.Dy())
previousWidth, previousHeight := 0, 0
if window.xCanvas != nil {
previousWidth = window.xCanvas.Bounds().Dx()
previousHeight = window.xCanvas.Bounds().Dy()
}
newWidth := window.metrics.bounds.Dx()
newHeight := window.metrics.bounds.Dy()
larger := newWidth > previousWidth || newHeight > previousHeight
smaller := newWidth < previousWidth / 2 || newHeight < previousHeight / 2
allocStep := 128
if larger || smaller {
if window.xCanvas != nil {
window.xCanvas.Destroy()
}
window.xCanvas = xgraphics.New (
window.backend.connection,
image.Rect (
0, 0,
(newWidth / allocStep + 1) * allocStep,
(newHeight / allocStep + 1) * allocStep))
window.xCanvas.CreatePixmap()
}
}
func (window *window) pasteAndPush (region image.Rectangle) {
window.paste(region)
window.pushRegion(region)
}
func (window *window) paste (region image.Rectangle) {
canvas := canvas.Cut(window.canvas, region)
data, stride := canvas.Buffer()
bounds := canvas.Bounds().Intersect(window.xCanvas.Bounds())
dstStride := window.xCanvas.Stride
dstData := window.xCanvas.Pix
for y := bounds.Min.Y; y < bounds.Max.Y; y ++ {
srcYComponent := y * stride
dstYComponent := y * dstStride
for x := bounds.Min.X; x < bounds.Max.X; x ++ {
rgba := data[srcYComponent + x]
index := dstYComponent + x * 4
dstData[index + 0] = rgba.B
dstData[index + 1] = rgba.G
dstData[index + 2] = rgba.R
dstData[index + 3] = rgba.A
}
}
}
func (window *window) pushRegion (region image.Rectangle) {
if window.xCanvas == nil { panic("whoopsie!!!!!!!!!!!!!!") }
image, ok := window.xCanvas.SubImage(region).(*xgraphics.Image)
if ok {
image.XDraw()
image.XExpPaint (
window.xWindow.Id,
image.Bounds().Min.X,
image.Bounds().Min.Y)
}
}
func (window *window) setMinimumSize (width, height int) {
if width < 8 { width = 8 }
if height < 8 { height = 8 }
icccm.WmNormalHintsSet (
window.backend.connection,
window.xWindow.Id,
&icccm.NormalHints {
Flags: icccm.SizeHintPMinSize,
MinWidth: uint(width),
MinHeight: uint(height),
})
newWidth := window.metrics.bounds.Dx()
newHeight := window.metrics.bounds.Dy()
if newWidth < width { newWidth = width }
if newHeight < height { newHeight = height }
if newWidth != window.metrics.bounds.Dx() ||
newHeight != window.metrics.bounds.Dy() {
window.xWindow.Resize(newWidth, newHeight)
}
}

123
plugins/x/x/x.go Normal file
View File

@@ -0,0 +1,123 @@
package x
import "git.tebibyte.media/sashakoshka/tomo"
import "github.com/jezek/xgbutil"
import "github.com/jezek/xgb/xproto"
import "github.com/jezek/xgbutil/xevent"
import "github.com/jezek/xgbutil/keybind"
import "github.com/jezek/xgbutil/mousebind"
// Backend is an instance of an X backend.
type Backend struct {
connection *xgbutil.XUtil
doChannel chan(func ())
modifierMasks struct {
capsLock uint16
shiftLock uint16
numLock uint16
modeSwitch uint16
alt uint16
meta uint16
super uint16
hyper uint16
}
theme tomo.Theme
config tomo.Config
windows map[xproto.Window] *window
open bool
}
// NewBackend instantiates an X backend.
func NewBackend () (output tomo.Backend, err error) {
backend := &Backend {
windows: map[xproto.Window] *window { },
doChannel: make(chan func (), 32),
open: true,
}
// connect to X
backend.connection, err = xgbutil.NewConn()
if err != nil { return }
backend.initializeKeymapInformation()
keybind.Initialize(backend.connection)
mousebind.Initialize(backend.connection)
output = backend
return
}
// Run runs the backend's event loop. This method will not exit until Stop() is
// called, or the backend experiences a fatal error.
func (backend *Backend) Run () (err error) {
backend.assert()
pingBefore,
pingAfter,
pingQuit := xevent.MainPing(backend.connection)
for {
select {
case <- pingBefore:
<- pingAfter
case callback := <- backend.doChannel:
callback()
case <- pingQuit:
return
}
for _, window := range backend.windows {
window.system.afterEvent()
}
}
}
// Stop gracefully closes the connection and stops the event loop.
func (backend *Backend) Stop () {
backend.assert()
if !backend.open { return }
backend.open = false
toClose := []*window { }
for _, window := range backend.windows {
toClose = append(toClose, window)
}
for _, window := range toClose {
window.Close()
}
xevent.Quit(backend.connection)
backend.connection.Conn().Close()
}
// Do executes the specified callback within the main thread as soon as
// possible. This function can be safely called from other threads.
func (backend *Backend) Do (callback func ()) {
backend.assert()
backend.doChannel <- callback
}
// SetTheme sets the theme of all open windows.
func (backend *Backend) SetTheme (theme tomo.Theme) {
backend.assert()
backend.theme = theme
for _, window := range backend.windows {
window.SetTheme(theme)
}
}
// SetConfig sets the configuration of all open windows.
func (backend *Backend) SetConfig (config tomo.Config) {
backend.assert()
backend.config = config
for _, window := range backend.windows {
window.SetConfig(config)
}
}
func (backend *Backend) assert () {
if backend == nil { panic("nil backend") }
}