// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- import Clutter from 'gi://Clutter'; import GObject from 'gi://GObject'; import Meta from 'gi://Meta'; import St from 'gi://St'; import * as Main from './main.js'; export const PopupAnimation = { NONE: 0, SLIDE: 1 << 0, FADE: 1 << 1, FULL: ~0, }; const POPUP_ANIMATION_TIME = 150; /** * BoxPointer: * * 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. * */ export const BoxPointer = GObject.registerClass({ Signals: { 'arrow-side-changed': {} }, }, class BoxPointer extends St.Widget { /** * @param {*} arrowSide side to draw the arrow on * @param {*} binProperties Properties to set on contained bin */ _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'); } });