diff options
Diffstat (limited to 'js/ui/boxpointer.js')
-rw-r--r-- | js/ui/boxpointer.js | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/js/ui/boxpointer.js b/js/ui/boxpointer.js new file mode 100644 index 0000000..3987d62 --- /dev/null +++ b/js/ui/boxpointer.js @@ -0,0 +1,654 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported BoxPointer */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var PopupAnimation = { + NONE: 0, + SLIDE: 1 << 0, + FADE: 1 << 1, + FULL: ~0, +}; + +var POPUP_ANIMATION_TIME = 150; + +/** + * BoxPointer: + * @side: side to draw the arrow on + * @binProperties: Properties to set on contained bin + * + * An actor which displays a triangle "arrow" pointing to a given + * side. The .bin property is a container in which content can be + * placed. The arrow position may be controlled via + * setArrowOrigin(). The arrow side might be temporarily flipped + * depending on the box size and source position to keep the box + * totally inside the monitor workarea if possible. + * + */ +var BoxPointer = GObject.registerClass({ + Signals: { 'arrow-side-changed': {} }, +}, class BoxPointer extends St.Widget { + _init(arrowSide, binProperties) { + super._init(); + + this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._arrowSide = arrowSide; + this._userArrowSide = arrowSide; + this._arrowOrigin = 0; + this._arrowActor = null; + this.bin = new St.Bin(binProperties); + this.add_actor(this.bin); + this._border = new St.DrawingArea(); + this._border.connect('repaint', this._drawBorder.bind(this)); + this.add_actor(this._border); + this.set_child_above_sibling(this.bin, this._border); + this._sourceAlignment = 0.5; + this._muteKeys = true; + this._muteInput = true; + + this.connect('notify::visible', () => { + if (this.visible) + Meta.disable_unredirect_for_display(global.display); + else + Meta.enable_unredirect_for_display(global.display); + }); + } + + vfunc_captured_event(event) { + if (event.type() === Clutter.EventType.ENTER || + event.type() === Clutter.EventType.LEAVE) + return Clutter.EVENT_PROPAGATE; + + let mute = event.type() === Clutter.EventType.KEY_PRESS || + event.type() === Clutter.EventType.KEY_RELEASE + ? this._muteKeys : this._muteInput; + + if (mute) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + get arrowSide() { + return this._arrowSide; + } + + open(animate, onComplete) { + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.FADE) + this.opacity = 0; + else + this.opacity = 255; + + this._muteKeys = false; + this.show(); + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + this.translation_y = -rise; + break; + case St.Side.BOTTOM: + this.translation_y = rise; + break; + case St.Side.LEFT: + this.translation_x = -rise; + break; + case St.Side.RIGHT: + this.translation_x = rise; + break; + } + } + + this.ease({ + opacity: 255, + translation_x: 0, + translation_y: 0, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this._muteInput = false; + if (onComplete) + onComplete(); + }, + }); + } + + close(animate, onComplete) { + if (!this.visible) + return; + + let translationX = 0; + let translationY = 0; + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let fade = animate & PopupAnimation.FADE; + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + translationY = rise; + break; + case St.Side.BOTTOM: + translationY = -rise; + break; + case St.Side.LEFT: + translationX = rise; + break; + case St.Side.RIGHT: + translationX = -rise; + break; + } + } + + this._muteInput = true; + this._muteKeys = true; + + this.remove_all_transitions(); + this.ease({ + opacity: fade ? 0 : 255, + translation_x: translationX, + translation_y: translationY, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this.hide(); + this.opacity = 0; + this.translation_x = 0; + this.translation_y = 0; + if (onComplete) + onComplete(); + }, + }); + } + + _adjustAllocationForArrow(isWidth, minSize, natSize) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + minSize += borderWidth * 2; + natSize += borderWidth * 2; + if ((!isWidth && (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM)) || + (isWidth && (this._arrowSide == St.Side.LEFT || this._arrowSide == St.Side.RIGHT))) { + let rise = themeNode.get_length('-arrow-rise'); + minSize += rise; + natSize += rise; + } + + return [minSize, natSize]; + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + forHeight = themeNode.adjust_for_height(forHeight); + + let width = this.bin.get_preferred_width(forHeight); + width = this._adjustAllocationForArrow(true, ...width); + + return themeNode.adjust_preferred_width(...width); + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + forWidth = themeNode.adjust_for_width(forWidth); + + let height = this.bin.get_preferred_height(forWidth - 2 * borderWidth); + height = this._adjustAllocationForArrow(false, ...height); + + return themeNode.adjust_preferred_height(...height); + } + + vfunc_allocate(box) { + if (this._sourceActor && this._sourceActor.mapped) { + this._reposition(box); + this._updateFlip(box); + } + + this.set_allocation(box); + + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let rise = themeNode.get_length('-arrow-rise'); + let childBox = new Clutter.ActorBox(); + let [availWidth, availHeight] = themeNode.get_content_box(box).get_size(); + + childBox.x1 = 0; + childBox.y1 = 0; + childBox.x2 = availWidth; + childBox.y2 = availHeight; + this._border.allocate(childBox); + + childBox.x1 = borderWidth; + childBox.y1 = borderWidth; + childBox.x2 = availWidth - borderWidth; + childBox.y2 = availHeight - borderWidth; + switch (this._arrowSide) { + case St.Side.TOP: + childBox.y1 += rise; + break; + case St.Side.BOTTOM: + childBox.y2 -= rise; + break; + case St.Side.LEFT: + childBox.x1 += rise; + break; + case St.Side.RIGHT: + childBox.x2 -= rise; + break; + } + this.bin.allocate(childBox); + } + + _drawBorder(area) { + let themeNode = this.get_theme_node(); + + if (this._arrowActor) { + let [sourceX, sourceY] = this._arrowActor.get_transformed_position(); + let [sourceWidth, sourceHeight] = this._arrowActor.get_transformed_size(); + let [absX, absY] = this.get_transformed_position(); + + if (this._arrowSide == St.Side.TOP || + this._arrowSide == St.Side.BOTTOM) + this._arrowOrigin = sourceX - absX + sourceWidth / 2; + else + this._arrowOrigin = sourceY - absY + sourceHeight / 2; + } + + let borderWidth = themeNode.get_length('-arrow-border-width'); + let base = themeNode.get_length('-arrow-base'); + let rise = themeNode.get_length('-arrow-rise'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + + let halfBorder = borderWidth / 2; + let halfBase = Math.floor(base / 2); + + let [width, height] = area.get_surface_size(); + let [boxWidth, boxHeight] = [width, height]; + if (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM) + boxHeight -= rise; + else + boxWidth -= rise; + + let cr = area.get_context(); + + // Translate so that box goes from 0,0 to boxWidth,boxHeight, + // with the arrow poking out of that + if (this._arrowSide == St.Side.TOP) + cr.translate(0, rise); + else if (this._arrowSide == St.Side.LEFT) + cr.translate(rise, 0); + + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [boxWidth - halfBorder, boxHeight - halfBorder]; + + let skipTopLeft = false; + let skipTopRight = false; + let skipBottomLeft = false; + let skipBottomRight = false; + + if (rise) { + switch (this._arrowSide) { + case St.Side.TOP: + if (this._arrowOrigin == x1) + skipTopLeft = true; + else if (this._arrowOrigin == x2) + skipTopRight = true; + break; + + case St.Side.RIGHT: + if (this._arrowOrigin == y1) + skipTopRight = true; + else if (this._arrowOrigin == y2) + skipBottomRight = true; + break; + + case St.Side.BOTTOM: + if (this._arrowOrigin == x1) + skipBottomLeft = true; + else if (this._arrowOrigin == x2) + skipBottomRight = true; + break; + + case St.Side.LEFT: + if (this._arrowOrigin == y1) + skipTopLeft = true; + else if (this._arrowOrigin == y2) + skipBottomLeft = true; + break; + } + } + + cr.moveTo(x1 + borderRadius, y1); + if (this._arrowSide == St.Side.TOP && rise) { + if (skipTopLeft) { + cr.moveTo(x1, y2 - borderRadius); + cr.lineTo(x1, y1 - rise); + cr.lineTo(x1 + halfBase, y1); + } else if (skipTopRight) { + cr.lineTo(x2 - halfBase, y1); + cr.lineTo(x2, y1 - rise); + cr.lineTo(x2, y1 + borderRadius); + } else { + cr.lineTo(this._arrowOrigin - halfBase, y1); + cr.lineTo(this._arrowOrigin, y1 - rise); + cr.lineTo(this._arrowOrigin + halfBase, y1); + } + } + + if (!skipTopRight) { + cr.lineTo(x2 - borderRadius, y1); + cr.arc(x2 - borderRadius, y1 + borderRadius, borderRadius, + 3 * Math.PI / 2, Math.PI * 2); + } + + if (this._arrowSide == St.Side.RIGHT && rise) { + if (skipTopRight) { + cr.lineTo(x2 + rise, y1); + cr.lineTo(x2 + rise, y1 + halfBase); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 - halfBase); + cr.lineTo(x2 + rise, y2); + cr.lineTo(x2 - borderRadius, y2); + } else { + cr.lineTo(x2, this._arrowOrigin - halfBase); + cr.lineTo(x2 + rise, this._arrowOrigin); + cr.lineTo(x2, this._arrowOrigin + halfBase); + } + } + + if (!skipBottomRight) { + cr.lineTo(x2, y2 - borderRadius); + cr.arc(x2 - borderRadius, y2 - borderRadius, borderRadius, + 0, Math.PI / 2); + } + + if (this._arrowSide == St.Side.BOTTOM && rise) { + if (skipBottomLeft) { + cr.lineTo(x1 + halfBase, y2); + cr.lineTo(x1, y2 + rise); + cr.lineTo(x1, y2 - borderRadius); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 + rise); + cr.lineTo(x2 - halfBase, y2); + } else { + cr.lineTo(this._arrowOrigin + halfBase, y2); + cr.lineTo(this._arrowOrigin, y2 + rise); + cr.lineTo(this._arrowOrigin - halfBase, y2); + } + } + + if (!skipBottomLeft) { + cr.lineTo(x1 + borderRadius, y2); + cr.arc(x1 + borderRadius, y2 - borderRadius, borderRadius, + Math.PI / 2, Math.PI); + } + + if (this._arrowSide == St.Side.LEFT && rise) { + if (skipTopLeft) { + cr.lineTo(x1, y1 + halfBase); + cr.lineTo(x1 - rise, y1); + cr.lineTo(x1 + borderRadius, y1); + } else if (skipBottomLeft) { + cr.lineTo(x1 - rise, y2); + cr.lineTo(x1 - rise, y2 - halfBase); + } else { + cr.lineTo(x1, this._arrowOrigin + halfBase); + cr.lineTo(x1 - rise, this._arrowOrigin); + cr.lineTo(x1, this._arrowOrigin - halfBase); + } + } + + if (!skipTopLeft) { + cr.lineTo(x1, y1 + borderRadius); + cr.arc(x1 + borderRadius, y1 + borderRadius, borderRadius, + Math.PI, 3 * Math.PI / 2); + } + + const [hasColor, bgColor] = + themeNode.lookup_color('-arrow-background-color', false); + if (hasColor) { + Clutter.cairo_set_source_color(cr, bgColor); + cr.fillPreserve(); + } + + if (borderWidth > 0) { + let borderColor = themeNode.get_color('-arrow-border-color'); + Clutter.cairo_set_source_color(cr, borderColor); + cr.setLineWidth(borderWidth); + cr.stroke(); + } + + cr.$dispose(); + } + + setPosition(sourceActor, alignment) { + if (!this._sourceActor || sourceActor != this._sourceActor) { + this._sourceActor?.disconnectObject(this); + + this._sourceActor = sourceActor; + + this._sourceActor?.connectObject('destroy', + () => (this._sourceActor = null), this); + } + + this._arrowAlignment = alignment; + + this.queue_relayout(); + } + + setSourceAlignment(alignment) { + this._sourceAlignment = alignment; + + if (!this._sourceActor) + return; + + this.setPosition(this._sourceActor, this._arrowAlignment); + } + + _reposition(allocationBox) { + let sourceActor = this._sourceActor; + let alignment = this._arrowAlignment; + let monitorIndex = Main.layoutManager.findIndexForActor(sourceActor); + + this._sourceExtents = sourceActor.get_transformed_extents(); + this._workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + + // Position correctly relative to the sourceActor + const sourceAllocation = sourceActor.get_allocation_box(); + const sourceContentBox = sourceActor instanceof St.Widget + ? sourceActor.get_theme_node().get_content_box(sourceAllocation) + : new Clutter.ActorBox({ + x2: sourceAllocation.get_width(), + y2: sourceAllocation.get_height(), + }); + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let sourceCenterX = sourceTopLeft.x + sourceContentBox.x1 + (sourceContentBox.x2 - sourceContentBox.x1) * this._sourceAlignment; + let sourceCenterY = sourceTopLeft.y + sourceContentBox.y1 + (sourceContentBox.y2 - sourceContentBox.y1) * this._sourceAlignment; + let [, , natWidth, natHeight] = this.get_preferred_size(); + + // We also want to keep it onscreen, and separated from the + // edge by the same distance as the main part of the box is + // separated from its sourceActor + let workarea = this._workArea; + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let arrowBase = themeNode.get_length('-arrow-base'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + let margin = 4 * borderRadius + borderWidth + arrowBase; + + let gap = themeNode.get_length('-boxpointer-gap'); + let padding = themeNode.get_length('-arrow-rise'); + + let resX, resY; + + switch (this._arrowSide) { + case St.Side.TOP: + resY = sourceBottomRight.y + gap; + break; + case St.Side.BOTTOM: + resY = sourceTopLeft.y - natHeight - gap; + break; + case St.Side.LEFT: + resX = sourceBottomRight.x + gap; + break; + case St.Side.RIGHT: + resX = sourceTopLeft.x - natWidth - gap; + break; + } + + // Now align and position the pointing axis, making sure it fits on + // screen. If the arrowOrigin is so close to the edge that the arrow + // will not be isosceles, we try to compensate as follows: + // - We skip the rounded corner and settle for a right angled arrow + // as shown below. See _drawBorder for further details. + // |\_____ + // | + // | + // - If the arrow was going to be acute angled, we move the position + // of the box to maintain the arrow's accuracy. + + let arrowOrigin; + let halfBase = Math.floor(arrowBase / 2); + let halfBorder = borderWidth / 2; + let halfMargin = margin / 2; + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [natWidth - halfBorder, natHeight - halfBorder]; + + switch (this._arrowSide) { + case St.Side.TOP: + case St.Side.BOTTOM: + resX = sourceCenterX - (halfMargin + (natWidth - margin) * alignment); + + resX = Math.max(resX, workarea.x + padding); + resX = Math.min(resX, workarea.x + workarea.width - (padding + natWidth)); + + arrowOrigin = sourceCenterX - resX; + if (arrowOrigin <= (x1 + (borderRadius + halfBase))) { + if (arrowOrigin > x1) + resX += arrowOrigin - x1; + arrowOrigin = x1; + } else if (arrowOrigin >= (x2 - (borderRadius + halfBase))) { + if (arrowOrigin < x2) + resX -= x2 - arrowOrigin; + arrowOrigin = x2; + } + break; + + case St.Side.LEFT: + case St.Side.RIGHT: + resY = sourceCenterY - (halfMargin + (natHeight - margin) * alignment); + + resY = Math.max(resY, workarea.y + padding); + resY = Math.min(resY, workarea.y + workarea.height - (padding + natHeight)); + + arrowOrigin = sourceCenterY - resY; + if (arrowOrigin <= (y1 + (borderRadius + halfBase))) { + if (arrowOrigin > y1) + resY += arrowOrigin - y1; + arrowOrigin = y1; + } else if (arrowOrigin >= (y2 - (borderRadius + halfBase))) { + if (arrowOrigin < y2) + resY -= y2 - arrowOrigin; + arrowOrigin = y2; + } + break; + } + + this.setArrowOrigin(arrowOrigin); + + let parent = this.get_parent(); + let success, x, y; + while (!success) { + [success, x, y] = parent.transform_stage_point(resX, resY); + parent = parent.get_parent(); + } + + // Actually set the position + allocationBox.set_origin(Math.floor(x), Math.floor(y)); + } + + // @origin: Coordinate specifying middle of the arrow, along + // the Y axis for St.Side.LEFT, St.Side.RIGHT from the top and X axis from + // the left for St.Side.TOP and St.Side.BOTTOM. + setArrowOrigin(origin) { + if (this._arrowOrigin != origin) { + this._arrowOrigin = origin; + this._border.queue_repaint(); + } + } + + // @actor: an actor relative to which the arrow is positioned. + // Differently from setPosition, this will not move the boxpointer itself, + // on the arrow + setArrowActor(actor) { + if (this._arrowActor != actor) { + this._arrowActor = actor; + this._border.queue_repaint(); + } + } + + _calculateArrowSide(arrowSide) { + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let [, , boxWidth, boxHeight] = this.get_preferred_size(); + let workarea = this._workArea; + + switch (arrowSide) { + case St.Side.TOP: + if (sourceBottomRight.y + boxHeight > workarea.y + workarea.height && + boxHeight < sourceTopLeft.y - workarea.y) + return St.Side.BOTTOM; + break; + case St.Side.BOTTOM: + if (sourceTopLeft.y - boxHeight < workarea.y && + boxHeight < workarea.y + workarea.height - sourceBottomRight.y) + return St.Side.TOP; + break; + case St.Side.LEFT: + if (sourceBottomRight.x + boxWidth > workarea.x + workarea.width && + boxWidth < sourceTopLeft.x - workarea.x) + return St.Side.RIGHT; + break; + case St.Side.RIGHT: + if (sourceTopLeft.x - boxWidth < workarea.x && + boxWidth < workarea.x + workarea.width - sourceBottomRight.x) + return St.Side.LEFT; + break; + } + + return arrowSide; + } + + _updateFlip(allocationBox) { + let arrowSide = this._calculateArrowSide(this._userArrowSide); + if (this._arrowSide != arrowSide) { + this._arrowSide = arrowSide; + this._reposition(allocationBox); + + this.emit('arrow-side-changed'); + } + } + + updateArrowSide(side) { + this._arrowSide = side; + this._border.queue_repaint(); + + this.emit('arrow-side-changed'); + } + + getPadding(side) { + return this.bin.get_theme_node().get_padding(side); + } + + getArrowHeight() { + return this.get_theme_node().get_length('-arrow-rise'); + } +}); |