diff --git a/README.md b/README.md index 619f68d..42c72f8 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,30 @@ run siwinGlobals.newSoftwareRenderingWindow(title="pixie example"), WindowEvents ) ``` +

popup windows

+ +This api adds popup windows. On Wayland these are required to do popups, but on other platforms these will just be frameless windows. + +```nim +import siwin, vmath + +let globals = newSiwinGlobals() +let parent = globals.newSoftwareRenderingWindow() + +let placement = PopupPlacement( + anchorRectPos: ivec2(100, 100), + anchorRectSize: ivec2(120, 40), + size: ivec2(320, 220), + anchor: Edge.bottomLeft, + gravity: Edge.topLeft, + offset: ivec2(0, 8), + constraintAdjustment: {PopupConstraintAdjustment.pcaSlideX, PopupConstraintAdjustment.pcaFlipY}, + reactive: true, +) + +let popup = globals.newPopupWindow(parent, placement) +``` +

clipboard

```nim diff --git a/examples/popup_demo.nim b/examples/popup_demo.nim new file mode 100644 index 0000000..d142e59 --- /dev/null +++ b/examples/popup_demo.nim @@ -0,0 +1,371 @@ +import std/[strformat, math] +import vmath +import siwin +import siwin/colorutils + +const + TitleBarHeight = 52'i32 + CloseButtonSize = 40'i32 + CloseButtonMargin = 12'i32 + ResizeBorderWidth = 8'f32 + ResizeCornerWidth = 18'f32 + + PopupSize = ivec2(520, 420) + ButtonSize = ivec2(220, 68) + +type + MainDemoState = object + rgbaBuffer: seq[Color32bit] + hoverButton: bool + + PopupDemoState = object + rgbaBuffer: seq[Color32bit] + hoverClose: bool + +proc rgba(r, g, b: byte, a: byte = 255): Color32bit = + [r, g, b, a] + +proc insideRect(pos: Vec2, x, y, w, h: int32): bool = + pos.x >= x.float32 and pos.x < (x + w).float32 and pos.y >= y.float32 and + pos.y < (y + h).float32 + +proc ensureBuffer[T](rgbaBuffer: var seq[T], size: IVec2) = + let pixelCount = max(1, (size.x * size.y).int) + if rgbaBuffer.len != pixelCount: + rgbaBuffer.setLen(pixelCount) + +proc fill(rgbaBuffer: var seq[Color32bit], size: IVec2, color: Color32bit) = + for i in 0 ..< size.x * size.y: + rgbaBuffer[i] = color + +proc fillRect( + rgbaBuffer: var seq[Color32bit], size: IVec2, x, y, w, h: int32, color: Color32bit +) = + let + x0 = max(0, x) + y0 = max(0, y) + x1 = min(size.x, x + w) + y1 = min(size.y, y + h) + + if x0 >= x1 or y0 >= y1: + return + + for py in y0 ..< y1: + let row = py * size.x + for px in x0 ..< x1: + rgbaBuffer[row + px] = color + +proc px(scale: float32, value: int32): int32 = + round(value.float64 * scale.float64).int32 + +proc pf(scale: float32, value: float32): float32 = + (value.float64 * scale.float64).float32 + +proc popupButtonRect(size: IVec2): tuple[x, y, w, h: int32] = + ( + x: max(28, (size.x - ButtonSize.x) div 2), + y: max(32, (size.y - ButtonSize.y) div 2), + w: ButtonSize.x, + h: ButtonSize.y, + ) + +proc closeButtonRect(size: IVec2): tuple[x, y, w, h: int32] = + ( + x: size.x - CloseButtonSize - CloseButtonMargin, + y: (TitleBarHeight - CloseButtonSize) div 2, + w: CloseButtonSize, + h: CloseButtonSize, + ) + +proc sizeToString(size: IVec2): string = + fmt"({size.x}, {size.y})" + +proc posToString(pos: Vec2): string = + fmt"({pos.x:.1f}, {pos.y:.1f})" + +proc rectToString(rect: tuple[x, y, w, h: int32]): string = + fmt"(x={rect.x}, y={rect.y}, w={rect.w}, h={rect.h})" + +proc logPopupCloseButton(window: Window, reason: string) = + let size = window.size + let closeRect = closeButtonRect(size) + echo fmt"[popup_demo] {reason}: popupSize={sizeToString(size)} closeButton={rectToString(closeRect)}" + +proc logPopupPlacementState(reason: string, parent, popup: Window, placement: PopupPlacement) = + let expectedPos = parent.pos + placement.popupRelativePos() + echo fmt"[popup_demo] {reason}: parentPos={sizeToString(parent.pos)} popupPos={sizeToString(popup.pos)} expectedPos={sizeToString(expectedPos)} relativePos={sizeToString(placement.popupRelativePos())} popupSize={sizeToString(popup.size)}" + +proc logPopupPointer( + kind: string, + window: Window, + pos: Vec2, + button: MouseButton, + pressed = false, + double = false, +) = + let closeRect = closeButtonRect(window.size) + let insideClose = + insideRect(pos, closeRect.x, closeRect.y, closeRect.w, closeRect.h) + echo fmt"[popup_demo] {kind}: button={$button} pressed={pressed} double={double} pos={posToString(pos)} closeButton={rectToString(closeRect)} insideClose={insideClose}" + +proc popupPlacement(size: IVec2): PopupPlacement = + let buttonRect = popupButtonRect(size) + PopupPlacement( + anchorRectPos: ivec2(buttonRect.x, buttonRect.y), + anchorRectSize: ivec2(buttonRect.w, buttonRect.h), + size: PopupSize, + anchor: Edge.bottomLeft, + gravity: Edge.bottomLeft, + offset: ivec2(0, 14), + constraintAdjustment: {}, + reactive: false, + ) + +proc applyPopupDragRegions(window: Window) = + let size = window.size + let titleWidth = max(1'i32, size.x - CloseButtonSize - CloseButtonMargin * 2) + window.setTitleRegion(vec2(0, 0), vec2(titleWidth.float32, TitleBarHeight.float32)) + window.setBorderWidth(ResizeBorderWidth, 0, ResizeCornerWidth) + window.logPopupCloseButton("applyPopupDragRegions") + +proc drawMainWindow(state: var MainDemoState, size: IVec2, popupOpen: bool) = + state.rgbaBuffer.ensureBuffer(size) + state.rgbaBuffer.fill(size, rgba(245, 247, 250)) + + state.rgbaBuffer.fillRect(size, 48, 52, size.x - 96, 84, rgba(225, 231, 239)) + state.rgbaBuffer.fillRect(size, 48, 164, size.x - 96, 132, rgba(255, 255, 255)) + state.rgbaBuffer.fillRect( + size, 48, size.y - 132, size.x - 96, 72, rgba(225, 231, 239) + ) + + let buttonRect = popupButtonRect(size) + let buttonColor = + if popupOpen: + rgba(48, 103, 214) + elif state.hoverButton: + rgba(71, 125, 232) + else: + rgba(37, 88, 196) + state.rgbaBuffer.fillRect( + size, buttonRect.x, buttonRect.y, buttonRect.w, buttonRect.h, buttonColor + ) + state.rgbaBuffer.fillRect( + size, + buttonRect.x + 6, + buttonRect.y + 6, + buttonRect.w - 12, + buttonRect.h - 12, + rgba(243, 247, 255), + ) + state.rgbaBuffer.fillRect( + size, + buttonRect.x + 18, + buttonRect.y + 18, + buttonRect.w - 36, + buttonRect.h - 36, + buttonColor, + ) + +proc drawCloseGlyph( + rgbaBuffer: var seq[Color32bit], size: IVec2, rect: tuple[x, y, w, h: int32] +) = + let glyphInset = 12 + for i in 0 ..< (rect.w - glyphInset * 2): + let x1 = rect.x + glyphInset + i + let y1 = rect.y + glyphInset + i + let x2 = rect.x + rect.w - glyphInset - 1 - i + let y2 = rect.y + glyphInset + i + if x1 >= 0 and x1 < size.x and y1 >= 0 and y1 < size.y: + rgbaBuffer[y1 * size.x + x1] = rgba(245, 247, 250) + if x2 >= 0 and x2 < size.x and y2 >= 0 and y2 < size.y: + rgbaBuffer[y2 * size.x + x2] = rgba(245, 247, 250) + +proc drawPopupWindow(state: var PopupDemoState, size: IVec2) = + state.rgbaBuffer.ensureBuffer(size) + state.rgbaBuffer.fill(size, rgba(238, 241, 245)) + + state.rgbaBuffer.fillRect(size, 0, 0, size.x, TitleBarHeight, rgba(23, 34, 46)) + state.rgbaBuffer.fillRect( + size, 18, 82, size.x - 36, size.y - 100, rgba(255, 255, 255) + ) + state.rgbaBuffer.fillRect(size, 42, 118, size.x - 84, 92, rgba(226, 232, 240)) + state.rgbaBuffer.fillRect(size, 42, 232, size.x - 84, 92, rgba(212, 226, 255)) + state.rgbaBuffer.fillRect(size, 42, 346, (size.x - 96) div 2, 42, rgba(255, 224, 201)) + state.rgbaBuffer.fillRect( + size, size.x div 2 + 8, 346, (size.x - 96) div 2, 42, rgba(208, 244, 226) + ) + + let closeRect = closeButtonRect(size) + state.rgbaBuffer.fillRect( + size, + closeRect.x, + closeRect.y, + closeRect.w, + closeRect.h, + (if state.hoverClose: rgba(206, 62, 68) else: rgba(153, 33, 40)), + ) + state.rgbaBuffer.drawCloseGlyph(size, closeRect) + +let globals = newSiwinGlobals() +let window = + globals.newSoftwareRenderingWindow(size = ivec2(300, 200), title = "siwin popup demo") + +var + mainState: MainDemoState + popupState: PopupDemoState + popup: PopupWindow + +proc popupIsOpen(): bool = + popup != nil + +proc updatePopupPlacement() = + if popup != nil and popup.opened: + let placement = window.size.popupPlacement() + popup.reposition(placement) + logPopupPlacementState("updatePopupPlacement", window, popup, placement) + +proc closePopup() = + if popup != nil and popup.opened: + popup.close() + +proc installPopupHandlers() = + popup.eventsHandler = WindowEventsHandler( + onResize: proc(e: ResizeEvent) = + e.window.applyPopupDragRegions() + if popup != nil: + logPopupPlacementState("popup.onResize", window, e.window, e.window.placement) + redraw e.window + , + onRender: proc(e: RenderEvent) = + let pixelBuffer = e.window.pixelBuffer + popupState.drawPopupWindow(pixelBuffer.size) + copyMem( + pixelBuffer.data, + popupState.rgbaBuffer[0].addr, + popupState.rgbaBuffer.len * sizeof(Color32bit), + ) + convertPixelsInplace( + pixelBuffer.data, pixelBuffer.size, PixelBufferFormat.rgba_32bit, + pixelBuffer.format, + ), + onMouseMove: proc(e: MouseMoveEvent) = + let closeRect = closeButtonRect(e.window.size) + let newHover = + insideRect(e.pos, closeRect.x, closeRect.y, closeRect.w, closeRect.h) + if popupState.hoverClose != newHover: + popupState.hoverClose = newHover + redraw e.window + , + onMouseButton: proc(e: MouseButtonEvent) = + if e.button == MouseButton.left: + logPopupPointer( + "onMouseButton", + e.window, + e.window.mouse.pos, + e.button, + pressed = e.pressed, + ) + if e.button == MouseButton.left and e.pressed: + let closeRect = closeButtonRect(e.window.size) + if insideRect( + e.window.mouse.pos, + closeRect.x, + closeRect.y, + closeRect.w, + closeRect.h, + ): + e.window.close() + , + onClick: proc(e: ClickEvent) = + if e.button == MouseButton.left: + logPopupPointer( + "onClick", + e.window, + e.pos, + e.button, + double = e.double, + ) + let closeRect = closeButtonRect(e.window.size) + if e.button == MouseButton.left and + insideRect(e.pos, closeRect.x, closeRect.y, closeRect.w, closeRect.h): + e.window.close() + , + onKey: proc(e: KeyEvent) = + if e.pressed and not e.generated and e.key == Key.escape: + e.window.close() + , + onClose: proc(e: CloseEvent) = + popup = nil + popupState.hoverClose = false + redraw window + , + ) + +proc openPopup() = + if popup != nil: + return + + let placement = window.size.popupPlacement() + popup = globals.newPopupWindow(window, placement, grab = true) + popupState.hoverClose = false + popup.applyPopupDragRegions() + installPopupHandlers() + popup.logPopupCloseButton("openPopup") + logPopupPlacementState("openPopup", window, popup, placement) + popup.firstStep(makeVisible = true) + redraw popup + redraw window + +window.eventsHandler = WindowEventsHandler( + onResize: proc(e: ResizeEvent) = + updatePopupPlacement() + redraw e.window + , + onWindowMove: proc(e: WindowMoveEvent) = + updatePopupPlacement(), + onRender: proc(e: RenderEvent) = + let pixelBuffer = e.window.pixelBuffer + mainState.drawMainWindow(pixelBuffer.size, popupIsOpen()) + copyMem( + pixelBuffer.data, + mainState.rgbaBuffer[0].addr, + mainState.rgbaBuffer.len * sizeof(Color32bit), + ) + convertPixelsInplace( + pixelBuffer.data, pixelBuffer.size, PixelBufferFormat.rgba_32bit, + pixelBuffer.format, + ), + onMouseMove: proc(e: MouseMoveEvent) = + let buttonRect = popupButtonRect(e.window.size) + let newHover = + insideRect(e.pos, buttonRect.x, buttonRect.y, buttonRect.w, buttonRect.h) + if mainState.hoverButton != newHover: + mainState.hoverButton = newHover + redraw e.window + , + onClick: proc(e: ClickEvent) = + let buttonRect = popupButtonRect(e.window.size) + if e.button == MouseButton.left and + insideRect(e.pos, buttonRect.x, buttonRect.y, buttonRect.w, buttonRect.h): + if popupIsOpen(): + closePopup() + else: + openPopup() + , + onClose: proc(e: CloseEvent) = + closePopup(), + onKey: proc(e: KeyEvent) = + if e.pressed and not e.generated and e.key == Key.escape: + if popup != nil: + closePopup() + else: + close e.window + , +) + +window.firstStep(makeVisible = true) +while window.opened or popup != nil: + if window.opened: + window.step() + if popup != nil: + popup.step() diff --git a/src/siwin/platforms/any/window.nim b/src/siwin/platforms/any/window.nim index f3717c3..51421ed 100644 --- a/src/siwin/platforms/any/window.nim +++ b/src/siwin/platforms/any/window.nim @@ -122,6 +122,29 @@ type rejected accepted + PopupConstraintAdjustment* {.siwin_enum.} = enum + pcaSlideX + pcaSlideY + pcaFlipX + pcaFlipY + pcaResizeX + pcaResizeY + + PopupDismissReason* {.siwin_enum.} = enum + pdrClientClosed + pdrCompositorDismissed + pdrParentClosed + + PopupPlacement* = object + anchorRectPos*: IVec2 + anchorRectSize*: IVec2 + size*: IVec2 + anchor*: Edge + gravity*: Edge + offset*: IVec2 + constraintAdjustment*: set[PopupConstraintAdjustment] + reactive*: bool + AnyWindowEvent* = object of RootObj window*: Window @@ -191,6 +214,9 @@ type value*: bool kind*: StateBoolChangedEventKind isExternal*: bool ## changed by user via compositor (server-side change) + + PopupEvent* = object of AnyWindowEvent + reason*: PopupDismissReason DropEvent* = object of AnyWindowEvent @@ -219,6 +245,8 @@ type ## binary state of focus/fullscreen/maximized/frameless changed ## fullscreen and maximized changes are sent before ResizeEvent + onPopupDone*: proc(e: PopupEvent) ## popup was dismissed or explicitly closed + onDrop*: proc(e: DropEvent) ## drag&drop clipboard content is beeng pasted to this window @@ -240,6 +268,11 @@ type m_frameless: bool m_cursor: Cursor m_separateTouch: bool + m_isPopup: bool + m_popupGrab: bool + m_popupDismissed: bool + m_popupParent: Window + m_popupPlacement: PopupPlacement m_size: IVec2 m_pos: IVec2 @@ -267,6 +300,106 @@ method height*(screen: Screen): int32 {.base.} = discard proc size*(screen: Screen): IVec2 = ivec2(screen.width, screen.height) +type PopupWindow* = Window + +func popupSize*(placement: PopupPlacement): IVec2 = + if placement.size.x > 0 and placement.size.y > 0: + placement.size + elif placement.anchorRectSize.x > 0 and placement.anchorRectSize.y > 0: + placement.anchorRectSize + else: + ivec2(1, 1) + +func popupAnchorOffset*(anchor: Edge, size: IVec2): IVec2 = + case anchor + of Edge.topLeft: ivec2(0, 0) + of Edge.top: ivec2(size.x div 2, 0) + of Edge.topRight: ivec2(size.x, 0) + of Edge.left: ivec2(0, size.y div 2) + of Edge.right: ivec2(size.x, size.y div 2) + of Edge.bottomLeft: ivec2(0, size.y) + of Edge.bottom: ivec2(size.x div 2, size.y) + of Edge.bottomRight: ivec2(size.x, size.y) + +func popupRelativePos*(placement: PopupPlacement): IVec2 = + let anchorPoint = placement.anchorRectPos + placement.anchor.popupAnchorOffset(placement.anchorRectSize) + anchorPoint - placement.gravity.popupAnchorOffset(placement.popupSize()) + placement.offset + +proc flipPopupEdgeX*(edge: Edge): Edge = + case edge + of Edge.topLeft: Edge.topRight + of Edge.topRight: Edge.topLeft + of Edge.left: Edge.right + of Edge.right: Edge.left + of Edge.bottomLeft: Edge.bottomRight + of Edge.bottomRight: Edge.bottomLeft + else: edge + +proc flipPopupEdgeY*(edge: Edge): Edge = + case edge + of Edge.topLeft: Edge.bottomLeft + of Edge.top: Edge.bottom + of Edge.topRight: Edge.bottomRight + of Edge.bottomLeft: Edge.topLeft + of Edge.bottom: Edge.top + of Edge.bottomRight: Edge.topRight + else: edge + +proc popupOverflowX*(posX, width, boundsWidth: int32): int32 {.inline.} = + max(0'i32, -posX) + max(0'i32, posX + width - boundsWidth) + +proc popupOverflowY*(posY, height, boundsHeight: int32): int32 {.inline.} = + max(0'i32, -posY) + max(0'i32, posY + height - boundsHeight) + +proc resolvePopupRect*(parentPos, boundsPos, boundsSize: IVec2, placement: PopupPlacement): tuple[pos, size: IVec2] = + proc popupRectFor(placement: PopupPlacement): tuple[pos, size: IVec2] = + (parentPos + placement.popupRelativePos(), placement.popupSize()) + + var resolvedPlacement = placement + result = popupRectFor(resolvedPlacement) + + if PopupConstraintAdjustment.pcaFlipX in placement.constraintAdjustment: + var flipped = resolvedPlacement + flipped.anchor = flipped.anchor.flipPopupEdgeX() + flipped.gravity = flipped.gravity.flipPopupEdgeX() + let flippedRect = popupRectFor(flipped) + if popupOverflowX(flippedRect.pos.x - boundsPos.x, flippedRect.size.x, boundsSize.x) < + popupOverflowX(result.pos.x - boundsPos.x, result.size.x, boundsSize.x): + resolvedPlacement = flipped + result = flippedRect + + if PopupConstraintAdjustment.pcaFlipY in placement.constraintAdjustment: + var flipped = resolvedPlacement + flipped.anchor = flipped.anchor.flipPopupEdgeY() + flipped.gravity = flipped.gravity.flipPopupEdgeY() + let flippedRect = popupRectFor(flipped) + if popupOverflowY(flippedRect.pos.y - boundsPos.y, flippedRect.size.y, boundsSize.y) < + popupOverflowY(result.pos.y - boundsPos.y, result.size.y, boundsSize.y): + resolvedPlacement = flipped + result = flippedRect + + if PopupConstraintAdjustment.pcaSlideX in placement.constraintAdjustment: + result.pos.x = clamp(result.pos.x, boundsPos.x, max(boundsPos.x, boundsPos.x + boundsSize.x - result.size.x)) + + if PopupConstraintAdjustment.pcaSlideY in placement.constraintAdjustment: + result.pos.y = clamp(result.pos.y, boundsPos.y, max(boundsPos.y, boundsPos.y + boundsSize.y - result.size.y)) + + if PopupConstraintAdjustment.pcaResizeX in placement.constraintAdjustment: + if result.pos.x < boundsPos.x: + result.size.x -= boundsPos.x - result.pos.x + result.pos.x = boundsPos.x + if result.pos.x + result.size.x > boundsPos.x + boundsSize.x: + result.size.x = max(1'i32, boundsPos.x + boundsSize.x - result.pos.x) + result.size.x = max(1'i32, result.size.x) + + if PopupConstraintAdjustment.pcaResizeY in placement.constraintAdjustment: + if result.pos.y < boundsPos.y: + result.size.y -= boundsPos.y - result.pos.y + result.pos.y = boundsPos.y + if result.pos.y + result.size.y > boundsPos.y + boundsSize.y: + result.size.y = max(1'i32, boundsPos.y + boundsSize.y - result.pos.y) + result.size.y = max(1'i32, result.size.y) + proc closed*(window: Window): bool = window.m_closed proc opened*(window: Window): bool = not window.closed @@ -280,6 +413,8 @@ proc frameless*(window: Window): bool = window.m_frameless proc cursor*(window: Window): Cursor = window.m_cursor proc separateTouch*(window: Window): bool = window.m_separateTouch ## enable/disable handling touch events separately from mouse events +proc isPopup*(window: Window): bool = window.m_isPopup +proc popupGrab*(window: Window): bool = window.m_popupGrab method reportedSize*(window: Window): IVec2 {.base.} = window.m_size ## Size reported to API users/events (backing pixels on HiDPI platforms). @@ -295,6 +430,24 @@ proc minSize*(window: Window): IVec2 = window.m_minSize proc maxSize*(window: Window): IVec2 = window.m_maxSize proc focused*(window: Window): bool = window.m_focused +method parentWindow*(window: Window): Window {.base.} = window.m_popupParent +method placement*(window: Window): PopupPlacement {.base.} = window.m_popupPlacement +proc popupOpen*(window: Window): bool = window.opened and window.visible + +proc initPopupState*(window, parent: Window, placement: PopupPlacement, grab: bool) = + window.m_isPopup = true + window.m_popupGrab = grab + window.m_popupDismissed = false + window.m_popupParent = parent + window.m_popupPlacement = placement + window.m_size = placement.popupSize() + +proc notifyPopupDone*(window: Window, reason: PopupDismissReason) = + if not window.m_isPopup or window.m_popupDismissed: + return + window.m_popupDismissed = true + if window.eventsHandler.onPopupDone != nil: + window.eventsHandler.onPopupDone(PopupEvent(window: window, reason: reason)) method uiScale*(window: Window): float32 {.base.} = 1'f32 ## UI scale factor (device pixels per logical point). @@ -316,6 +469,12 @@ method `cursor=`*(window: Window, v: Cursor) {.base.} = discard method `separateTouch=`*(window: Window, v: bool) {.base.} = discard ## enable/disable handling touch events separately from mouse events +method `placement=`*(window: Window, v: PopupPlacement) {.base.} = + window.m_popupPlacement = v + +method reposition*(window: Window, v: PopupPlacement) {.base.} = + window.placement = v + method `size=`*(window: Window, v: IVec2) {.base.} = discard ## resize window diff --git a/src/siwin/platforms/cocoa/window.nim b/src/siwin/platforms/cocoa/window.nim index 2629642..ff065b2 100644 --- a/src/siwin/platforms/cocoa/window.nim +++ b/src/siwin/platforms/cocoa/window.nim @@ -1,4 +1,4 @@ -import std/[importutils, tables, times, os, unicode, uri, sequtils, strutils] +import std/[importutils, tables, times, os, unicode, uri, sequtils, strutils, strformat, math] import pkg/[vmath] from pkg/darwin/quartz_core/calayer import CALayer from pkg/darwin/quartz_core/cametal_layer import CAMetalLayer @@ -19,6 +19,8 @@ template autoreleasepool(body: untyped) = finally: pool.release() +proc isFlipped(v: NSView): bool {.objc: "isFlipped".} + type ScreenCocoa* = ref object of Screen id: int32 @@ -774,6 +776,7 @@ method close*(window: WindowCocoa) = if window.m_closed: return `=destroy` window[] + window.eventsHandler.pushEvent onClose, CloseEvent(window: window) method `size=`*(window: WindowCocoa, v: IVec2) = if v.x <= 0 or v.y <= 0: @@ -815,6 +818,93 @@ method `pos=`*(window: WindowCocoa, v: IVec2) = true ) +proc framePos*(window: WindowCocoa): IVec2 = + if window == nil or window.handle == nil: + return window.m_pos + + let frame = window.handle.frame + let screen = window.handle.screen + result = window.m_pos + if screen != nil: + let screenFrame = screen.frame + result = vec2( + frame.origin.x, + screenFrame.size.height - frame.origin.y - frame.size.height - 1 + ).ivec2 + +proc contentPos*(window: WindowCocoa): IVec2 = + let framePos = window.framePos() + if window == nil or window.handle == nil: + return framePos + + let frame = window.handle.frame + let contentRect = window.handle.contentRectForFrameRect(frame) + let leftInset = contentRect.origin.x - frame.origin.x + let topInset = + frame.size.height - + ((contentRect.origin.y - frame.origin.y) + contentRect.size.height) + result = ivec2( + framePos.x + leftInset.int32, + framePos.y + topInset.int32, + ) + +proc syncPosFromHandle(window: WindowCocoa) = + if window == nil or window.handle == nil: + return + window.m_pos = window.framePos() + +proc toPoints(distance: int32, scale: float32): int32 = + if scale <= 0'f32: + return distance + round(distance.float64 / scale.float64).int32 + +proc toPoints(size: IVec2, scale: float32): IVec2 = + ivec2( + max(1'i32, toPoints(size.x, scale)), + max(1'i32, toPoints(size.y, scale)), + ) + +proc setContentSizeInPoints(window: WindowCocoa, size: IVec2) = + let frame = window.handle.frame + let contentRect = window.handle.contentRectForFrameRect(frame) + let borderW = frame.size.width - contentRect.size.width + let borderH = frame.size.height - contentRect.size.height + window.handle.setFrame( + NSMakeRect( + frame.origin.x, + frame.origin.y, + size.x.float64 + borderW, + size.y.float64 + borderH + ), + true + ) + +proc applyPopupPlacement(window: WindowCocoa, placement: PopupPlacement) = + window.m_popupPlacement = placement + + if not window.m_isPopup: + return + + let parent = window.parentWindow + if parent == nil: + return + + let popupSize = placement.popupSize() + let parentScale = max(1'f32, parent.uiScale) + let popupSizePoints = popupSize.toPoints(parentScale) + if window.m_size != popupSize: + window.m_size = popupSize + if window of WindowCocoaSoftwareRendering: + window.WindowCocoaSoftwareRendering.resizeSoftwarePixelBuffer(popupSize) + window.setContentSizeInPoints(popupSizePoints) + + let parentContentPos = + if parent of WindowCocoa: + parent.WindowCocoa.contentPos() + else: + parent.pos + window.pos = parentContentPos + placement.popupRelativePos().toPoints(parentScale) + method `fullscreen=`*(window: WindowCocoa, v: bool) = if window.m_fullscreen == v: return @@ -884,6 +974,12 @@ method `maxSize=`*(window: WindowCocoa, v: IVec2) = window.m_maxSize = v window.handle.setMaxSize(NSMakeSize(v.x.float64, v.y.float64)) +method `placement=`*(window: WindowCocoa, v: PopupPlacement) = + window.applyPopupPlacement(v) + +method reposition*(window: WindowCocoa, v: PopupPlacement) = + window.placement = v + method `icon=`*(window: WindowCocoa, _: nil.typeof) = if NSApp == nil: discard NSApplication.sharedApplication() @@ -1076,20 +1172,40 @@ proc init = let bounds = contentView.bounds - backingBounds = contentView.convertRectToBacking(bounds) - backingPointRect = contentView.convertRectToBacking(NSMakeRect(location.x, location.y, 0, 0)) + boundsW = max(1e-6, bounds.size.width) + boundsH = max(1e-6, bounds.size.height) + y = + if contentView.isFlipped(): + location.y.float32 + else: + (bounds.size.height - location.y).float32 + scaleX = window.m_size.x.float32 / boundsW.float32 + scaleY = window.m_size.y.float32 / boundsH.float32 vec2( - backingPointRect.origin.x.float32, - (backingBounds.size.height - backingPointRect.origin.y).float32 + location.x.float32 * scaleX, + y * scaleY ) + proc logPopupMouseButton( + window: WindowCocoa, + phase: string, + button: MouseButton, + pressed: bool, + location: NsPoint, + pos: Vec2, + ) = + if not window.m_isPopup or button != MouseButton.left: + return + echo fmt"[siwin/cocoa popup] {phase}: button={$button} pressed={pressed} raw=({location.x:.1f}, {location.y:.1f}) pos=({pos.x:.1f}, {pos.y:.1f}) size=({window.m_size.x}, {window.m_size.y})" + proc updateMousePos(window: WindowCocoa, location: NsPoint, kind: MouseMoveKind) = window.mouse.pos = scaledMousePos(window, location) window.eventsHandler.pushEvent onMouseMove, MouseMoveEvent(window: window, pos: window.mouse.pos, kind: kind) proc handleMouseButton(window: WindowCocoa, button: MouseButton, pressed: bool, location: NsPoint) = window.mouse.pos = scaledMousePos(window, location) + window.logPopupMouseButton("mouseButton", button, pressed, location, window.mouse.pos) if pressed: window.mouse.pressed.incl button window.clicking.incl button @@ -1098,6 +1214,13 @@ proc init = window.mouse.pressed.excl button if button in window.clicking: + window.logPopupMouseButton( + "clickDispatch", + button, + pressed, + location, + window.mouse.pos, + ) window.eventsHandler.pushEvent onClick, ClickEvent( window: window, button: button, pos: window.mouse.pos, double: (nows - window.lastClickTime[button]).inMilliseconds < 200 @@ -1145,13 +1268,7 @@ proc init = addMethod "windowDidMove:", proc(self: Id, cmd: Sel, notification: NsNotification): Id {.cdecl.} = getWindow(self) autoreleasepool: - let - windowFrame = window.handle.frame - screenFrame = window.handle.screen.frame - window.m_pos = vec2( - windowFrame.origin.x, - screenFrame.size.height - windowFrame.origin.y - windowFrame.size.height - 1 - ).ivec2 + window.syncPosFromHandle() window.eventsHandler.pushEvent onWindowMove, WindowMoveEvent(window: window, pos: window.m_pos) addMethod "canBecomeKeyWindow", proc(self: Id, cmd: Sel): bool {.cdecl.} = @@ -1537,6 +1654,7 @@ method firstStep*(window: WindowCocoa, makeVisible = true) = if event == nil: break NSApp.sendEvent(event) + window.syncPosFromHandle() # Ensure all visible windows are present in the initial z-order. Without # this, some macOS setups only show the most recently shown window until # the user cycles windows manually. @@ -1615,6 +1733,26 @@ proc newSoftwareRenderingWindowCocoa*( result.title = title if not resizable: result.resizable = false +proc newPopupWindowCocoa*( + parent: WindowCocoa, placement: PopupPlacement, transparent = false, grab = true +): WindowCocoaSoftwareRendering = + if parent == nil: + raise ValueError.newException("Popup windows require a parent window") + result = newSoftwareRenderingWindowCocoa( + size = placement.popupSize(), + title = "", + screen = defaultScreenCocoa(), + resizable = false, + fullscreen = false, + frameless = true, + transparent = transparent, + ) + result.initPopupState(parent, placement, grab) + result.placement = placement + # Interactive popups need to accept focus to receive full mouse/key input. + result.canBecomeKeyWindow = true + result.canBecomeMainWindow = false + proc newOpenglWindowCocoa*( size = ivec2(1280, 720), diff --git a/src/siwin/platforms/wayland/window.nim b/src/siwin/platforms/wayland/window.nim index 7716b32..6fa5f47 100644 --- a/src/siwin/platforms/wayland/window.nim +++ b/src/siwin/platforms/wayland/window.nim @@ -16,6 +16,7 @@ type WindowWaylandKind* {.pure.} = enum XdgSurface + PopupSurface LayerSurface WindowWayland* = ref WindowWaylandObj @@ -24,6 +25,7 @@ type surface: Wl_surface xdgSurface: Xdg_surface xdgToplevel: Xdg_toplevel + xdgPopup: Xdg_popup serverDecoration: Zxdg_toplevel_decoration_v1 # will be nil if compositor doesn't support this protocol @@ -60,6 +62,8 @@ type lastKeyRepeatedTime: Time bufferScaleFactor: int32 fractionalScaleFactor: float32 + popupRepositionToken: uint32 + releasing: bool viewport: Wp_viewport fractionalScaleObj: Wp_fractional_scale_v1 toplevelIcon: Xdg_toplevel_icon_v1 @@ -78,6 +82,7 @@ type WindowWaylandSoftwareRenderingObj* = object of WindowWayland buffer: SharedBuffer oldBuffer: SharedBuffer + softwarePresentEnabled*: bool proc waylandKeyToKey(keycode: uint32): Key = @@ -308,6 +313,10 @@ method release(window: WindowWayland) {.base, raises: [].} = f x x = typeof(x).default + if window == nil or window.releasing: + return + window.releasing = true + if window.surface != nil: if window.globals.associatedWindows_queueRemove_insteadOf_removingInstantly: window.globals.associatedWindows_removeQueue.add window.surface.proxy.raw.id @@ -318,6 +327,7 @@ method release(window: WindowWayland) {.base, raises: [].} = clearToplevelIconResources(window) destroy window.idleInhibitor, destroy destroy window.layerShellSurface, destroy + destroy window.xdgPopup, destroy destroy window.fractionalScaleObj, destroy destroy window.viewport, destroy destroy window.plasmaSurface, destroy @@ -386,6 +396,16 @@ proc bufferSize(window: WindowWayland, logicalSize: IVec2): IVec2 {.inline.} = let scale = window.effectiveUiScale() ivec2(scaledBufferLength(logicalSize.x, scale), scaledBufferLength(logicalSize.y, scale)) +proc reportedCoord(window: WindowWayland; logical: int32): int32 {.inline.} = + let scale = window.effectiveUiScale() + if scale <= 1'f32: + logical + else: + round(logical.float32 * scale).int32 + +proc reportedPos(window: WindowWayland; logicalPos: IVec2): IVec2 {.inline.} = + ivec2(window.reportedCoord(logicalPos.x), window.reportedCoord(logicalPos.y)) + proc toLogicalLength(window: WindowWayland; backing: float32): float32 {.inline.} = let scale = window.effectiveUiScale() if scale <= 1'f32: @@ -396,6 +416,23 @@ proc toLogicalLength(window: WindowWayland; backing: float32): float32 {.inline. proc toLogicalLength(window: WindowWayland; backing: int32): int32 {.inline.} = max(1'i32, (window.toLogicalLength(backing.float32) + 0.5'f32).int32) +proc toLogicalCoord(window: WindowWayland; backing: int32): int32 {.inline.} = + if window == nil: + backing + else: + round(window.toLogicalLength(backing.float32)).int32 + +proc toLogicalSize(window: WindowWayland; backing: int32): int32 {.inline.} = + if backing <= 0: + backing + elif window == nil: + backing + else: + max(1'i32, round(window.toLogicalLength(backing.float32)).int32) + +proc toLogicalSize(window: WindowWayland; backing: IVec2): IVec2 {.inline.} = + ivec2(window.toLogicalSize(backing.x), window.toLogicalSize(backing.y)) + proc reportedPointerPos(window: WindowWayland, surfaceX, surfaceY: float32): Vec2 {.inline.} = let scale = window.effectiveUiScale() if scale <= 1'f32: @@ -621,6 +658,73 @@ proc setupOpaqueRegion(window: WindowWayland, size: IVec2, transparent: bool) = window.surface.set_opaque_region(opaqueRegion) destroy opaqueRegion +proc toXdgPositionerAnchor(edge: Edge): `Xdg_positioner / Anchor` = + case edge + of Edge.topLeft: `Xdg_positioner / Anchor`.top_left + of Edge.top: `Xdg_positioner / Anchor`.top + of Edge.topRight: `Xdg_positioner / Anchor`.top_right + of Edge.left: `Xdg_positioner / Anchor`.left + of Edge.right: `Xdg_positioner / Anchor`.right + of Edge.bottomLeft: `Xdg_positioner / Anchor`.bottom_left + of Edge.bottom: `Xdg_positioner / Anchor`.bottom + of Edge.bottomRight: `Xdg_positioner / Anchor`.bottom_right + +proc toXdgPositionerGravity(edge: Edge): `Xdg_positioner / Gravity` = + let xdgEdge = edge.flipPopupEdgeX().flipPopupEdgeY() + case xdgEdge + of Edge.topLeft: `Xdg_positioner / Gravity`.top_left + of Edge.top: `Xdg_positioner / Gravity`.top + of Edge.topRight: `Xdg_positioner / Gravity`.top_right + of Edge.left: `Xdg_positioner / Gravity`.left + of Edge.right: `Xdg_positioner / Gravity`.right + of Edge.bottomLeft: `Xdg_positioner / Gravity`.bottom_left + of Edge.bottom: `Xdg_positioner / Gravity`.bottom + of Edge.bottomRight: `Xdg_positioner / Gravity`.bottom_right + +proc toXdgConstraintAdjustment( + adjustments: set[PopupConstraintAdjustment] +): uint32 = + for adjustment in adjustments: + result = result or ( + case adjustment + of PopupConstraintAdjustment.pcaSlideX: + `Xdg_positioner / Constraint_adjustment`.slide_x.uint32 + of PopupConstraintAdjustment.pcaSlideY: + `Xdg_positioner / Constraint_adjustment`.slide_y.uint32 + of PopupConstraintAdjustment.pcaFlipX: + `Xdg_positioner / Constraint_adjustment`.flip_x.uint32 + of PopupConstraintAdjustment.pcaFlipY: + `Xdg_positioner / Constraint_adjustment`.flip_y.uint32 + of PopupConstraintAdjustment.pcaResizeX: + `Xdg_positioner / Constraint_adjustment`.resize_x.uint32 + of PopupConstraintAdjustment.pcaResizeY: + `Xdg_positioner / Constraint_adjustment`.resize_y.uint32 + ) + +proc createPopupPositioner(window: WindowWayland): Xdg_positioner = + let placement = window.m_popupPlacement + let parent = window.parentWindow().WindowWayland + let popupSize = parent.toLogicalSize(placement.popupSize()) + result = window.globals.xdgWmBase.create_positioner() + result.set_size(popupSize.x, popupSize.y) + result.set_anchor_rect( + parent.toLogicalCoord(placement.anchorRectPos.x), + parent.toLogicalCoord(placement.anchorRectPos.y), + max(1'i32, parent.toLogicalSize(placement.anchorRectSize.x)), + max(1'i32, parent.toLogicalSize(placement.anchorRectSize.y)), + ) + result.set_anchor(placement.anchor.toXdgPositionerAnchor()) + result.set_gravity(placement.gravity.toXdgPositionerGravity()) + result.set_constraint_adjustment(placement.constraintAdjustment.toXdgConstraintAdjustment()) + result.set_offset( + parent.toLogicalCoord(placement.offset.x), + parent.toLogicalCoord(placement.offset.y), + ) + if placement.reactive: + result.set_reactive() + if parent != nil: + result.set_parent_size(parent.m_size.x, parent.m_size.y) + proc resize(window: WindowWayland, size: IVec2) = if size.x <= 0 or size.y <= 0: @@ -636,6 +740,10 @@ proc resize(window: WindowWayland, size: IVec2) = window.toplevelSetMaxSize(window.m_size.x, window.m_size.y) window.eventsHandler.onResize.pushEvent ResizeEvent(window: window, size: window.size) + of WindowWaylandKind.PopupSurface: + if window.xdgSurface != nil: + window.xdgSurface.set_window_geometry(0, 0, window.m_size.x, window.m_size.y) + window.eventsHandler.onResize.pushEvent ResizeEvent(window: window, size: window.size) of WindowWaylandKind.LayerSurface: window.layerShellSurface.set_size(window.m_size.x.uint32, window.m_size.y.uint32) @@ -681,6 +789,7 @@ proc setFrameless(window: WindowWayland, v: bool) = method `fullscreen=`*(window: WindowWayland, v: bool) = + if window.kind != WindowWaylandKind.XdgSurface: return if window.m_fullscreen == v: return window.m_fullscreen = v window.toplevelSetFullscreen(v) @@ -689,6 +798,7 @@ method `fullscreen=`*(window: WindowWayland, v: bool) = method `frameless=`*(window: WindowWayland, v: bool) = if window.m_frameless == v: return window.m_frameless = v + if window.kind != WindowWaylandKind.XdgSurface: return if window.m_fullscreen: return # no system decorations needed for fullscreen windows window.setFrameless(v) @@ -728,6 +838,18 @@ method `cursor=`*(window: WindowWayland, v: Cursor) = if v.kind == builtin and window.m_cursor.kind == builtin and v.builtin == window.m_cursor.builtin: return ## todo +method reposition*(window: WindowWayland, v: PopupPlacement) = + procCall window.Window.reposition(v) + if window.kind != WindowWaylandKind.PopupSurface or window.xdgPopup == nil: + return + + let positioner = window.createPopupPositioner() + defer: destroy positioner + + inc window.popupRepositionToken + window.xdgPopup.reposition(positioner, window.popupRepositionToken) + redraw window + proc applyToplevelIcon(window: WindowWayland, icon: Xdg_toplevel_icon_v1) = let manager = window.globals.xdgToplevelIconManager if manager == nil or window.surface == nil: @@ -811,6 +933,8 @@ method pixelBuffer*(window: WindowWaylandSoftwareRendering): PixelBuffer = method swapBuffers(window: WindowWaylandSoftwareRendering) = if window.m_closed: return + if not window.softwarePresentEnabled: + return if window.buffer == nil: if window.m_size.x <= 0 or window.m_size.y <= 0: @@ -827,6 +951,7 @@ method swapBuffers(window: WindowWaylandSoftwareRendering) = method `maximized=`*(window: WindowWayland, v: bool) = + if window.kind != WindowWaylandKind.XdgSurface: return if window.m_maximized == v: return if window.fullscreen: window.fullscreen = false @@ -1018,7 +1143,7 @@ proc initSeatEvents*(globals: SiwinGlobalsWayland) = globals.associatedWindows_queueRemove_insteadOf_removingInstantly = false globals.removeQueuedAssociatedWindows() - for window in globals.associatedWindows.values: + for window in globals.associatedWindows.values.toSeq(): let window = window.WindowWayland if ( (window.mouse.pressed.len == 0) and @@ -1049,7 +1174,7 @@ proc initSeatEvents*(globals: SiwinGlobalsWayland) = globals.associatedWindows_queueRemove_insteadOf_removingInstantly = false globals.removeQueuedAssociatedWindows() - for window in globals.associatedWindows.values: + for window in globals.associatedWindows.values.toSeq(): let window = window.WindowWayland if ( (state != `WlPointer / Button_state`.released or button notin window.mouse.pressed) and @@ -1493,6 +1618,36 @@ proc setupWindow(window: WindowWayland, fullscreen, frameless, transparent: bool if window.globals.plasmaShell != nil: window.plasmaSurface = window.globals.plasmaShell.get_surface(window.surface) + of PopupSurface: + let parent = window.parentWindow().WindowWayland + if parent == nil: + raise ValueError.newException("Wayland popup windows require a parent window") + if parent.xdgSurface == nil: + raise ValueError.newException("Wayland popup parent must expose an xdg_surface") + + window.xdgSurface = window.globals.xdgWmBase.get_xdg_surface(window.surface) + let positioner = window.createPopupPositioner() + window.xdgPopup = window.xdgSurface.get_popup(parent.xdgSurface, positioner) + destroy positioner + + window.xdgSurface.onConfigure: + window.xdgSurface.ack_configure(serial) + redraw window + + window.setupOpaqueRegion(size, transparent) + window.m_frameless = true + + if window.m_popupGrab and window.globals.seat != nil and window.globals.lastSeatEventSerial != 0: + window.xdgPopup.grab(window.globals.seat, window.globals.lastSeatEventSerial) + + window.xdgPopup.onPopup_done: + window.notifyPopupDone(PopupDismissReason.pdrCompositorDismissed) + window.m_closed = true + + window.xdgPopup.onConfigure: + window.m_pos = parent.pos + window.reportedPos(ivec2(x, y)) + window.resize(ivec2(width, height)) + of LayerSurface: window.layerShellSurface = window.globals.layerShell.get_layer_surface( window.surface, @@ -1875,8 +2030,45 @@ proc newSoftwareRenderingWindowWayland*( ): WindowWaylandSoftwareRendering = new result result.globals = globals + result.softwarePresentEnabled = true result.initSoftwareRenderingWindow(size, screen, fullscreen, frameless, transparent, (if class == "": title else: class)) result.title = title if not resizable: result.resizable = false +proc newPopupWindowWayland*( + globals: SiwinGlobalsWayland, + parent: WindowWayland, + placement: PopupPlacement, + transparent = false, + grab = true, +): WindowWaylandSoftwareRendering = + if parent == nil: + raise ValueError.newException("Popup windows require a parent window") + let logicalPopupSize = parent.toLogicalSize(placement.popupSize()) + new result + result.globals = globals + result.softwarePresentEnabled = true + result.kind = WindowWaylandKind.PopupSurface + result.basicInitWindow(logicalPopupSize, globals.defaultScreenWayland()) + result.initPopupState(parent, placement, grab) + result.m_size = logicalPopupSize + result.setupWindow( + fullscreen = false, + frameless = true, + transparent = transparent, + size = logicalPopupSize, + class = "", + ) + result.buffer = result.globals.create( + result.globals.shm, + result.bufferSize(logicalPopupSize), + (if transparent: argb8888 else: xrgb8888), + bufferCount = 2, + ) + result.pos = parent.pos + placement.popupRelativePos() + result.visible = true + +proc setSoftwarePresentEnabled*(window: WindowWaylandSoftwareRendering, enabled: bool) = + window.softwarePresentEnabled = enabled + export Layer, LayerEdge, LayerInteractivityMode diff --git a/src/siwin/platforms/winapi/window.nim b/src/siwin/platforms/winapi/window.nim index 03d56dc..f66b30a 100644 --- a/src/siwin/platforms/winapi/window.nim +++ b/src/siwin/platforms/winapi/window.nim @@ -794,3 +794,20 @@ proc newSoftwareRenderingWindowWinapi*( result.initWindow(size, screen, fullscreen, frameless, transparent) result.title = title if not resizable: result.resizable = false + +proc newPopupWindowWinapi*( + parent: WindowWinapi, placement: PopupPlacement, transparent = false, grab = true +): WindowWinapiSoftwareRendering = + if parent == nil: + raise ValueError.newException("Popup windows require a parent window") + result = newSoftwareRenderingWindowWinapi( + size = placement.popupSize(), + title = "", + screen = defaultScreenWinapi(), + resizable = false, + fullscreen = false, + frameless = true, + transparent = transparent, + ) + result.initPopupState(parent, placement, grab) + result.pos = parent.pos + placement.popupRelativePos() diff --git a/src/siwin/platforms/x11/siwinGlobals.nim b/src/siwin/platforms/x11/siwinGlobals.nim index ca54770..4b115ab 100644 --- a/src/siwin/platforms/x11/siwinGlobals.nim +++ b/src/siwin/platforms/x11/siwinGlobals.nim @@ -17,7 +17,7 @@ type atoms*: tuple[ frameless, wmDeleteWindow, utf8String, netWmName, netWmIconName, netWmState, netWmStateFullscreen, netWmStateMaximizedHorz, netWmStateMaximizedVert, netWmStateHidden, netWmMoveResize, - netWmSyncRequest, netWmSyncRequestCounter, + netWmSyncRequest, netWmSyncRequestCounter, netFrameExtents, clipboard, siwin_clipboardTargetProperty, targets, text, primary, xDndAware, xDndEnter, xDndTypeList, xDndSelection, xDndPosition, xDndLeave, xDndDrop, xDndFinished, xDndStatus, xDndActionCopy, xDndActionPrivate : Atom @@ -56,6 +56,7 @@ proc newX11Globals*: SiwinGlobalsX11 {.raises: [OsError].} = result.atoms.netWmMoveResize = result.display.XInternAtom("_NET_WM_MOVERESIZE", 0) result.atoms.netWmSyncRequest = result.display.XInternAtom("_NET_WM_SYNC_REQUEST", 0) result.atoms.netWmSyncRequestCounter = result.display.XInternAtom("_NET_WM_SYNC_REQUEST_COUNTER", 0) + result.atoms.netFrameExtents = result.display.XInternAtom("_NET_FRAME_EXTENTS", 0) result.atoms.clipboard = result.display.XInternAtom("CLIPBOARD", 0) result.atoms.siwin_clipboardTargetProperty = result.display.XInternAtom("siwin_clipboardTargetProperty", 0) result.atoms.targets = result.display.XInternAtom("TARGETS", 0) diff --git a/src/siwin/platforms/x11/window.nim b/src/siwin/platforms/x11/window.nim index 5f93714..800faf4 100644 --- a/src/siwin/platforms/x11/window.nim +++ b/src/siwin/platforms/x11/window.nim @@ -1,7 +1,7 @@ when not (compiles do: import pkg/x11/xutil): {.error: "x11 library not installed, required to cross compile to linux\n please run `nimble install x11`".} -import std/[times, importutils, strformat, sequtils, os, options, tables, uri, strutils] +import std/[times, importutils, strformat, sequtils, os, options, tables, uri, strutils, dynlib] import pkg/[vmath, chroma] import pkg/x11/xlib except Screen import pkg/x11/x except Window, Cursor, Time @@ -87,12 +87,46 @@ type pixels: pointer softwarePresentEnabled*: bool + PXineramaScreenInfo = ptr XineramaScreenInfo + XineramaScreenInfo = object + screen_number: cint + x_org: int16 + y_org: int16 + width: int16 + height: int16 + + XineramaIsActiveProc = proc(dpy: PDisplay): XBool {.cdecl.} + XineramaQueryScreensProc = proc(dpy: PDisplay, number: Pcint): PXineramaScreenInfo {.cdecl.} + +var + xineramaLib: LibHandle + xineramaLoadAttempted = false + xineramaIsActiveProc: XineramaIsActiveProc + xineramaQueryScreensProc: XineramaQueryScreensProc + proc nativeDisplayHandle*(window: WindowX11): pointer = cast[pointer](window.globals.display) proc nativeWindowHandle*(window: WindowX11): uint64 = cast[uint64](window.handle) +proc xineramaAvailable(): bool = + if xineramaLoadAttempted: + return xineramaLib != nil and xineramaIsActiveProc != nil and xineramaQueryScreensProc != nil + + xineramaLoadAttempted = true + for libname in ["libXinerama.so.1", "libXinerama.so"]: + xineramaLib = loadLib(libname) + if xineramaLib != nil: + break + + if xineramaLib == nil: + return false + + xineramaIsActiveProc = cast[XineramaIsActiveProc](symAddr(xineramaLib, "XineramaIsActive")) + xineramaQueryScreensProc = cast[XineramaQueryScreensProc](symAddr(xineramaLib, "XineramaQueryScreens")) + xineramaIsActiveProc != nil and xineramaQueryScreensProc != nil + const libXExt* = when defined(macosx): @@ -303,6 +337,46 @@ proc geometry(globals: SiwinGlobalsX11, xwin: x.Window): tuple[root: x.Window; p discard globals.display.XGetGeometry(xwin, root.addr, x.addr, y.addr, w.addr, h.addr, borderW.addr, depth.addr) (root, ivec2(x.int32, y.int32), ivec2(w.int32, h.int32), borderW.int, depth.int) +proc absolutePos(globals: SiwinGlobalsX11, xwin: x.Window): IVec2 = + let geom = globals.geometry(xwin) + var + child: x.Window + rootX, rootY: cint + discard globals.display.XTranslateCoordinates( + xwin, + geom.root, + 0, + 0, + rootX.addr, + rootY.addr, + child.addr, + ) + ivec2(rootX.int32, rootY.int32) + +proc popupConstraintBounds(globals: SiwinGlobalsX11, anchorPos: IVec2): tuple[pos, size: IVec2] = + result = (ivec2(0, 0), globals.geometry(globals.display.DefaultRootWindow).size) + + if not xineramaAvailable(): + return + + if xineramaIsActiveProc(globals.display) == 0: + return + + var screenCount: cint + let screens = xineramaQueryScreensProc(globals.display, screenCount.addr) + if screens == nil or screenCount <= 0: + return + defer: + discard XFree(screens) + + for i in 0 ..< screenCount: + let screen = cast[ptr UncheckedArray[XineramaScreenInfo]](screens)[i] + let pos = ivec2(screen.x_org.int32, screen.y_org.int32) + let size = ivec2(screen.width.int32, screen.height.int32) + if anchorPos.x >= pos.x and anchorPos.x < pos.x + size.x and + anchorPos.y >= pos.y and anchorPos.y < pos.y + size.y: + return (pos, size) + proc asXImage(globals: SiwinGlobalsX11, data: pointer, size: IVec2, transparent = false): XImage = XImage( width: cint size.x, height: cint size.y, @@ -389,7 +463,6 @@ proc pushEvent[T](event: proc(e: T), args: T) = proc resizePixelBuffer(window: WindowX11SoftwareRendering, size: IVec2) = window.pixels = window.pixels.realloc(size.x * size.y * Color32bit.sizeof) - proc basicInitWindow(window: WindowX11; size: IVec2; screen: ScreenX11) = window.screen = screen.id window.m_size = size @@ -568,8 +641,30 @@ method `size=`*(window: WindowX11, v: IVec2) = method `pos=`*(window: WindowX11, v: IVec2) = if window.m_fullscreen: return + window.m_pos = v discard window.globals.display.XMoveWindow(window.handle, v.x.cint, v.y.cint) +method reposition*(window: WindowX11, v: PopupPlacement) = + procCall window.Window.reposition(v) + if not window.isPopup: + return + + let parent = window.parentWindow().WindowX11 + if parent == nil: + return + + let parentPos = parent.globals.absolutePos(parent.handle) + let bounds = parent.globals.popupConstraintBounds(parentPos + v.anchorRectPos) + let rect = resolvePopupRect( + parentPos, + bounds.pos, + bounds.size, + v, + ) + if window.m_size != rect.size: + window.size = rect.size + window.pos = rect.pos + proc setX11Cursor(window: WindowX11, v: Cursor) = if window.xCursor != 0: @@ -1023,7 +1118,7 @@ method firstStep*(window: WindowX11, makeVisible = true) = if makeVisible: window.visible = true - window.m_pos = window.globals.geometry(window.handle).pos + window.m_pos = window.globals.absolutePos(window.handle) window.mouse.pos = (window.globals.cursor().pos - window.m_pos).vec2 if window of WindowX11SoftwareRendering: @@ -1191,8 +1286,9 @@ method step*(window: WindowX11) = window.WindowX11SoftwareRendering.resizePixelBuffer(window.m_size) window.eventsHandler.onResize.pushEvent ResizeEvent(window: window, size: window.m_size, initial: false) - if ev.xconfigure.x.int != window.m_pos.x or ev.xconfigure.y.int != window.m_pos.y: - window.m_pos = ivec2(ev.xconfigure.x.int32, ev.xconfigure.y.int32) + let absolutePos = window.globals.absolutePos(window.handle) + if absolutePos != window.m_pos: + window.m_pos = absolutePos window.mouse.pos = (window.globals.cursor().pos - window.m_pos).vec2 window.eventsHandler.onWindowMove.pushEvent WindowMoveEvent(window: window, pos: window.m_pos) @@ -1525,5 +1621,69 @@ proc newSoftwareRenderingWindowX11*( result.title = title if not resizable: result.resizable = false +proc newPopupWindowX11*( + globals: SiwinGlobalsX11, + parent: WindowX11, + placement: PopupPlacement, + transparent = false, + grab = true, +): WindowX11SoftwareRendering = + if parent == nil: + raise ValueError.newException("Popup windows require a parent window") + new result + result.softwarePresentEnabled = true + result.globals = globals + let screen = globals.defaultScreenX11() + result.basicInitWindow(placement.popupSize(), screen) + + if transparent: + result.m_transparent = true + let root = globals.display.DefaultRootWindow + + var vi: XVisualInfo + discard globals.display.XMatchVisualInfo(screen.id, 32, TrueColor, vi.addr) + + let cmap = globals.display.XCreateColormap(root, vi.visual, AllocNone) + var swa = XSetWindowAttributes( + colormap: cmap, + override_redirect: 1, + save_under: 1, + ) + + result.handle = globals.display.XCreateWindow( + root, 0, 0, placement.popupSize().x.cuint, placement.popupSize().y.cuint, 0, + vi.depth, InputOutput, vi.visual, + CwColormap or CWOverrideRedirect or CWSaveUnder, + swa.addr + ) + else: + var swa = XSetWindowAttributes( + override_redirect: 1, + save_under: 1, + background_pixel: globals.display.BlackPixel(screen.id), + border_pixel: 0, + ) + result.handle = globals.display.XCreateWindow( + globals.display.DefaultRootWindow, 0, 0, placement.popupSize().x.cuint, placement.popupSize().y.cuint, 0, + CopyFromParent, InputOutput, nil, + CWOverrideRedirect or CWSaveUnder or CWBackPixel or CWBorderPixel, + swa.addr + ) + + result.setupWindow(fullscreen = false, frameless = true, class = "") + result.gc.gc = globals.display.XCreateGC(result.handle, GCForeground or GCBackground, result.gc.gcv.addr) + result.initPopupState(parent, placement, grab) + let parentPos = globals.absolutePos(parent.handle) + let bounds = globals.popupConstraintBounds(parentPos + placement.anchorRectPos) + let rect = resolvePopupRect( + parentPos, + bounds.pos, + bounds.size, + placement, + ) + if result.m_size != rect.size: + result.m_size = rect.size + result.pos = rect.pos + proc setSoftwarePresentEnabled*(window: WindowX11SoftwareRendering, enabled: bool) = window.softwarePresentEnabled = enabled diff --git a/src/siwin/window.nim b/src/siwin/window.nim index bc7098e..dd61252 100644 --- a/src/siwin/window.nim +++ b/src/siwin/window.nim @@ -119,6 +119,27 @@ when not siwin_use_lib: resizable, fullscreen, frameless, transparent ) + proc newPopupWindow*( + globals: SiwinGlobals, + parent: Window, + placement: PopupPlacement, + transparent = false, + grab = true, + ): PopupWindow = + when defined(android): + raise SiwinPlatformSupportDefect.newException("Popup windows are not supported on Android") + elif defined(linux) or defined(bsd): + if globals of SiwinGlobalsX11: + result = globals.SiwinGlobalsX11.newPopupWindowX11(parent.WindowX11, placement, transparent, grab) + elif globals of SiwinGlobalsWayland: + result = globals.SiwinGlobalsWayland.newPopupWindowWayland(parent.WindowWayland, placement, transparent, grab) + else: + raise SiwinPlatformSupportDefect.newException("Unsupported platform") + elif defined(windows): + result = newPopupWindowWinapi(parent.WindowWinapi, placement, transparent, grab) + elif defined(macosx): + result = newPopupWindowCocoa(parent.WindowCocoa, placement, transparent, grab) + when defined(android): proc loadExtensions*() = diff --git a/tests/tpopup_api.nim b/tests/tpopup_api.nim new file mode 100644 index 0000000..55a66e7 --- /dev/null +++ b/tests/tpopup_api.nim @@ -0,0 +1,308 @@ +import unittest +import std/math +import std/[os, osproc, strutils] +import std/strtabs +import pkg/vmath +import siwin/[window, platforms] + +when defined(macosx): + import siwin/platforms/cocoa/window + +proc toPoints(distance: int32, scale: float32): int32 = + if scale <= 0'f32: + return distance + round(distance.float64 / scale.float64).int32 + +proc toPoints(size: IVec2, scale: float32): IVec2 = + ivec2(toPoints(size.x, scale), toPoints(size.y, scale)) + +proc scalePlacementToPoints(placement: PopupPlacement, scale: float32): PopupPlacement = + PopupPlacement( + anchorRectPos: placement.anchorRectPos.toPoints(scale), + anchorRectSize: placement.anchorRectSize.toPoints(scale), + size: placement.size.toPoints(scale), + anchor: placement.anchor, + gravity: placement.gravity, + offset: placement.offset.toPoints(scale), + constraintAdjustment: placement.constraintAdjustment, + reactive: placement.reactive, + ) + +proc stepUntil(window: Window, predicate: proc(): bool, maxSteps = 32) = + for _ in 0 ..< maxSteps: + if predicate(): + break + window.step() + +type PopupProbeResult = object + platform: string + relPos: IVec2 + reportedSize: IVec2 + uiScale: float32 + +proc maxAbsComponent(v: IVec2): int32 = + max(abs(v.x), abs(v.y)) + +proc parseIvec2(value: string): IVec2 = + let parts = value.split(',') + if parts.len != 2: + raise newException(ValueError, "invalid ivec2: " & value) + ivec2(parseInt(parts[0]).int32, parseInt(parts[1]).int32) + +proc parsePopupProbeResult(output: string): PopupProbeResult = + for line in output.splitLines(): + if not line.startsWith("POPUP_RESULT "): + continue + + for field in line["POPUP_RESULT ".len .. ^1].splitWhitespace(): + let kv = field.split('=', maxsplit = 1) + if kv.len != 2: + continue + case kv[0] + of "platform": + result.platform = kv[1] + of "relPos": + result.relPos = parseIvec2(kv[1]) + of "reportedSize": + result.reportedSize = parseIvec2(kv[1]) + of "uiScale": + result.uiScale = parseFloat(kv[1]).float32 + else: + discard + + if result.platform.len != 0: + return + + raise newException(ValueError, "missing POPUP_RESULT line") + +proc popupProbePlacement(): PopupPlacement = + PopupPlacement( + anchorRectPos: ivec2(40, 100), + anchorRectSize: ivec2(120, 34), + size: ivec2(520, 420), + anchor: bottomLeft, + gravity: topLeft, + offset: ivec2(0, 14), + constraintAdjustment: {}, + reactive: false, + ) + +proc runPopupProbe(platform: Platform) = + let globals = newSiwinGlobals(platform) + let parent = globals.newSoftwareRenderingWindow( + size = ivec2(780, 630), + title = "popup probe parent", + ) + parent.firstStep(makeVisible = true) + + var + lastParentPos = ivec2(low(int32), low(int32)) + parentStableSteps = 0 + + for _ in 0 ..< 120: + parent.step() + if parent.pos == lastParentPos: + inc parentStableSteps + else: + parentStableSteps = 0 + lastParentPos = parent.pos + if parentStableSteps >= 8: + break + + let placement = popupProbePlacement() + let popup = globals.newPopupWindow(parent, placement, grab = true) + popup.firstStep(makeVisible = true) + + var + lastRelPos = ivec2(low(int32), low(int32)) + lastReportedSize = ivec2(low(int32), low(int32)) + stableSteps = 0 + + for _ in 0 ..< 180: + parent.step() + popup.step() + + let relPos = popup.pos - parent.pos + let reportedSize = popup.size + if relPos == lastRelPos and reportedSize == lastReportedSize: + inc stableSteps + else: + stableSteps = 0 + lastRelPos = relPos + lastReportedSize = reportedSize + + if stableSteps >= 8: + break + + echo "POPUP_RESULT platform=", $platform, " relPos=", lastRelPos.x, ",", lastRelPos.y, + " reportedSize=", lastReportedSize.x, ",", lastReportedSize.y, + " uiScale=", popup.uiScale + + close popup + close parent + quit(0) + +proc runPopupProbeSubprocess(platform: Platform; waylandDebug = false): tuple[output: string, exitCode: int] = + var env = newStringTable() + for key, value in envPairs(): + env[key] = value + env["SIWIN_POPUP_TEST_HELPER"] = $platform + if waylandDebug: + env["WAYLAND_DEBUG"] = "1" + execCmdEx(getAppFilename().quoteShell & " --popup-runtime-helper", env = env) + +when defined(linux) or defined(bsd): + if getEnv("SIWIN_POPUP_TEST_HELPER").len != 0 and paramCount() > 0 and paramStr(1) == "--popup-runtime-helper": + runPopupProbe(parseEnum[Platform](getEnv("SIWIN_POPUP_TEST_HELPER"))) + +suite "siwin popup api": + test "popup placement resolves size and relative position": + let placement = PopupPlacement( + anchorRectPos: ivec2(20, 30), + anchorRectSize: ivec2(80, 24), + size: ivec2(120, 200), + anchor: bottomLeft, + gravity: topLeft, + offset: ivec2(3, 4), + constraintAdjustment: + {PopupConstraintAdjustment.pcaSlideX, PopupConstraintAdjustment.pcaFlipY}, + reactive: true, + ) + + check placement.popupSize() == ivec2(120, 200) + check placement.popupRelativePos() == ivec2(23, 58) + + test "popup placement falls back to anchor rect size": + let placement = PopupPlacement( + anchorRectPos: ivec2(10, 12), + anchorRectSize: ivec2(40, 18), + anchor: topRight, + gravity: bottomRight, + offset: ivec2(-2, -3), + ) + + check placement.popupSize() == ivec2(40, 18) + check placement.popupRelativePos() == ivec2(8, -9) + + when defined(macosx): + test "cocoa firstStep syncs initial window position": + let globals = newSiwinGlobals(Platform.cocoa) + let parent = globals.newSoftwareRenderingWindow( + size = ivec2(300, 200), + title = "popup parent initial pos", + ) + parent.firstStep(makeVisible = true) + parent.step() + + check parent.pos == parent.WindowCocoa.framePos() + + close parent + + test "cocoa popup placement uses parent content position": + let globals = newSiwinGlobals(Platform.cocoa) + let parent = globals.newSoftwareRenderingWindow( + size = ivec2(300, 200), + title = "popup parent", + ) + parent.pos = ivec2(140, 160) + parent.firstStep(makeVisible = false) + parent.step() + + let scale = parent.uiScale + let initialPlacement = PopupPlacement( + anchorRectPos: ivec2(parent.size.x div 3, parent.size.y div 4), + anchorRectSize: ivec2(parent.size.x div 5, 48), + size: ivec2(240, 180), + anchor: bottomRight, + gravity: topLeft, + offset: ivec2(0, 14), + ) + let popup = globals.newPopupWindow(parent, initialPlacement, grab = true) + popup.firstStep(makeVisible = false) + popup.step() + + check popup.pos == parent.WindowCocoa.contentPos() + initialPlacement.popupRelativePos().toPoints(scale) + check popup.size == initialPlacement.popupSize() + + let updatedPlacement = PopupPlacement( + anchorRectPos: ivec2(parent.size.x div 2, parent.size.y div 3), + anchorRectSize: ivec2(96, 56), + size: ivec2(140, 110), + anchor: bottomRight, + gravity: topRight, + offset: ivec2(-8, 10), + ) + popup.reposition(updatedPlacement) + popup.step() + + check popup.pos == parent.WindowCocoa.contentPos() + updatedPlacement.popupRelativePos().toPoints(scale) + check popup.size == updatedPlacement.popupSize() + + close popup + close parent + + when defined(linux) or defined(bsd): + test "wayland and x11 popup final geometry match for popup probe": + if Platform.wayland notin availablePlatforms() or Platform.x11 notin availablePlatforms(): + skip() + + let wayland = runPopupProbeSubprocess(Platform.wayland, waylandDebug = true) + require wayland.exitCode == 0 + check "xdg_popup" in wayland.output + check "configure(" in wayland.output + + let x11 = runPopupProbeSubprocess(Platform.x11) + require x11.exitCode == 0 + + let waylandResult = parsePopupProbeResult(wayland.output) + let x11Result = parsePopupProbeResult(x11.output) + check maxAbsComponent(waylandResult.relPos - x11Result.relPos) <= 1 + check maxAbsComponent(waylandResult.reportedSize - x11Result.reportedSize) <= 1 + + test "x11 popup window position matches relative placement in unconstrained case": + if Platform.x11 notin availablePlatforms(): + skip() + + let globals = newSiwinGlobals(Platform.x11) + let parent = globals.newSoftwareRenderingWindow( + size = ivec2(300, 200), + title = "popup parent", + ) + parent.firstStep(makeVisible = false) + parent.step() + + let placement = PopupPlacement( + anchorRectPos: ivec2(40, 48), + anchorRectSize: ivec2(96, 32), + size: ivec2(140, 110), + anchor: bottomLeft, + gravity: topLeft, + offset: ivec2(0, 14), + ) + let popup = globals.newPopupWindow(parent, placement, grab = true) + popup.firstStep(makeVisible = false) + popup.stepUntil(proc(): bool = popup.pos == parent.pos + placement.popupRelativePos() and popup.size == placement.popupSize()) + + check popup.pos == parent.pos + placement.popupRelativePos() + check popup.size == placement.popupSize() + + let updatedPlacement = PopupPlacement( + anchorRectPos: ivec2(120, 76), + anchorRectSize: ivec2(84, 28), + size: ivec2(120, 96), + anchor: topRight, + gravity: bottomRight, + offset: ivec2(-6, -8), + ) + popup.reposition(updatedPlacement) + popup.stepUntil( + proc(): bool = + popup.pos == parent.pos + updatedPlacement.popupRelativePos() and + popup.size == updatedPlacement.popupSize() + ) + + check popup.pos == parent.pos + updatedPlacement.popupRelativePos() + check popup.size == updatedPlacement.popupSize() + + close popup + close parent