summaryrefslogtreecommitdiffstats
path: root/js/ui/screenshot.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/screenshot.js')
-rw-r--r--js/ui/screenshot.js2897
1 files changed, 2897 insertions, 0 deletions
diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js
new file mode 100644
index 0000000..5139052
--- /dev/null
+++ b/js/ui/screenshot.js
@@ -0,0 +1,2897 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ScreenshotService, ScreenshotUI, showScreenshotUI, captureScreenshot */
+
+const { Clutter, Cogl, Gio, GObject, GLib, Graphene, Gtk, Meta, Shell, St } = imports.gi;
+
+const GrabHelper = imports.ui.grabHelper;
+const Layout = imports.ui.layout;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const Workspace = imports.ui.workspace;
+
+Gio._promisify(Shell.Screenshot.prototype, 'pick_color');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_window');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_area');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_stage_to_content');
+Gio._promisify(Shell.Screenshot, 'composite_to_stream');
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+const { DBusSenderChecker } = imports.misc.util;
+
+const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot');
+
+const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast');
+const ScreencastProxy = Gio.DBusProxy.makeProxyWrapper(ScreencastIface);
+
+var IconLabelButton = GObject.registerClass(
+class IconLabelButton extends St.Button {
+ _init(iconName, label, params) {
+ super._init(params);
+
+ this._container = new St.BoxLayout({
+ vertical: true,
+ style_class: 'icon-label-button-container',
+ });
+ this.set_child(this._container);
+
+ this._container.add_child(new St.Icon({ icon_name: iconName }));
+ this._container.add_child(new St.Label({
+ text: label,
+ x_align: Clutter.ActorAlign.CENTER,
+ }));
+ }
+});
+
+var Tooltip = GObject.registerClass(
+class Tooltip extends St.Label {
+ _init(widget, params) {
+ super._init(params);
+
+ this._widget = widget;
+ this._timeoutId = null;
+
+ this._widget.connect('notify::hover', () => {
+ if (this._widget.hover)
+ this.open();
+ else
+ this.close();
+ });
+ }
+
+ open() {
+ if (this._timeoutId)
+ return;
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => {
+ this.opacity = 0;
+ this.show();
+
+ const extents = this._widget.get_transformed_extents();
+
+ const xOffset = Math.floor((extents.get_width() - this.width) / 2);
+ const x =
+ Math.clamp(extents.get_x() + xOffset, 0, global.stage.width - this.width);
+
+ const node = this.get_theme_node();
+ const yOffset = node.get_length('-y-offset');
+
+ const y = extents.get_y() - this.height - yOffset;
+
+ this.set_position(x, y);
+ this.ease({
+ opacity: 255,
+ duration: 150,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this._timeoutId = null;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] tooltip.open');
+ }
+
+ close() {
+ if (this._timeoutId) {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = null;
+ return;
+ }
+
+ if (!this.visible)
+ return;
+
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 0,
+ duration: 100,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.hide(),
+ });
+ }
+});
+
+var UIAreaIndicator = GObject.registerClass(
+class UIAreaIndicator extends St.Widget {
+ _init(params) {
+ super._init(params);
+
+ this._topRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._topRect.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this.add_child(this._topRect);
+
+ this._bottomRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._bottomRect.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this.add_child(this._bottomRect);
+
+ this._leftRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._topRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._bottomRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this.add_child(this._leftRect);
+
+ this._rightRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.RIGHT,
+ to_edge: Clutter.SnapEdge.RIGHT,
+ }));
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._topRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._bottomRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this.add_child(this._rightRect);
+
+ this._selectionRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-selection' });
+ this.add_child(this._selectionRect);
+
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.RIGHT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.RIGHT,
+ }));
+ }
+
+ setSelectionRect(x, y, width, height) {
+ this._selectionRect.set_position(x, y);
+ this._selectionRect.set_size(width, height);
+ }
+});
+
+var UIAreaSelector = GObject.registerClass({
+ Signals: { 'drag-started': {}, 'drag-ended': {} },
+}, class UIAreaSelector extends St.Widget {
+ _init(params) {
+ super._init(params);
+
+ // During a drag, this can be Clutter.BUTTON_PRIMARY,
+ // Clutter.BUTTON_SECONDARY or the string "touch" to identify the source
+ // of the drag operation.
+ this._dragButton = 0;
+ this._dragSequence = null;
+
+ this._areaIndicator = new UIAreaIndicator();
+ this._areaIndicator.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this.add_child(this._areaIndicator);
+
+ this._topLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._topLeftHandle);
+ this._topRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._topRightHandle);
+ this._bottomLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._bottomLeftHandle);
+ this._bottomRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._bottomRightHandle);
+
+ // This will be updated before the first drawn frame.
+ this._handleSize = 0;
+ this._topLeftHandle.connect('style-changed', widget => {
+ this._handleSize = widget.get_theme_node().get_width();
+ this._updateSelectionRect();
+ });
+
+ this.connect('notify::mapped', () => {
+ if (this.mapped) {
+ const [x, y] = global.get_pointer();
+ this._updateCursor(x, y);
+ }
+ });
+
+ // Initialize area to out of bounds so reset() below resets it.
+ this._startX = -1;
+ this._startY = 0;
+ this._lastX = 0;
+ this._lastY = 0;
+
+ this.reset();
+ }
+
+ reset() {
+ this.stopDrag();
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ // Preserve area selection if possible. If the area goes out of bounds,
+ // the monitors might have changed, so reset the area.
+ const [x, y, w, h] = this.getGeometry();
+ if (x < 0 || y < 0 || x + w > this.width || y + h > this.height) {
+ // Initialize area to out of bounds so if there's no monitor,
+ // the area will be reset once a monitor does appear.
+ this._startX = -1;
+ this._startY = 0;
+ this._lastX = 0;
+ this._lastY = 0;
+
+ // This can happen when running headless without any monitors.
+ if (Main.layoutManager.primaryIndex !== -1) {
+ const monitor =
+ Main.layoutManager.monitors[Main.layoutManager.primaryIndex];
+
+ this._startX = monitor.x + Math.floor(monitor.width * 3 / 8);
+ this._startY = monitor.y + Math.floor(monitor.height * 3 / 8);
+ this._lastX = monitor.x + Math.floor(monitor.width * 5 / 8) - 1;
+ this._lastY = monitor.y + Math.floor(monitor.height * 5 / 8) - 1;
+ }
+
+ this._updateSelectionRect();
+ }
+ }
+
+ getGeometry() {
+ const leftX = Math.min(this._startX, this._lastX);
+ const topY = Math.min(this._startY, this._lastY);
+ const rightX = Math.max(this._startX, this._lastX);
+ const bottomY = Math.max(this._startY, this._lastY);
+
+ return [leftX, topY, rightX - leftX + 1, bottomY - topY + 1];
+ }
+
+ _updateSelectionRect() {
+ const [x, y, w, h] = this.getGeometry();
+ this._areaIndicator.setSelectionRect(x, y, w, h);
+
+ const offset = this._handleSize / 2;
+ this._topLeftHandle.set_position(x - offset, y - offset);
+ this._topRightHandle.set_position(x + w - 1 - offset, y - offset);
+ this._bottomLeftHandle.set_position(x - offset, y + h - 1 - offset);
+ this._bottomRightHandle.set_position(x + w - 1 - offset, y + h - 1 - offset);
+ }
+
+ _computeCursorType(cursorX, cursorY) {
+ const [leftX, topY, width, height] = this.getGeometry();
+ const [rightX, bottomY] = [leftX + width - 1, topY + height - 1];
+ const [x, y] = [cursorX, cursorY];
+
+ // Check if the cursor overlaps the handles first.
+ const limit = (this._handleSize / 2) ** 2;
+ if ((leftX - x) ** 2 + (topY - y) ** 2 <= limit)
+ return Meta.Cursor.NW_RESIZE;
+ else if ((rightX - x) ** 2 + (topY - y) ** 2 <= limit)
+ return Meta.Cursor.NE_RESIZE;
+ else if ((leftX - x) ** 2 + (bottomY - y) ** 2 <= limit)
+ return Meta.Cursor.SW_RESIZE;
+ else if ((rightX - x) ** 2 + (bottomY - y) ** 2 <= limit)
+ return Meta.Cursor.SE_RESIZE;
+
+ // Now check the rest of the rectangle.
+ const threshold =
+ 10 * St.ThemeContext.get_for_stage(global.stage).scaleFactor;
+
+ if (leftX - x >= 0 && leftX - x <= threshold) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NW_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SW_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.WEST_RESIZE;
+ } else if (x - rightX >= 0 && x - rightX <= threshold) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NE_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SE_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.EAST_RESIZE;
+ } else if (leftX - x < 0 && x - rightX < 0) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NORTH_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SOUTH_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.MOVE_OR_RESIZE_WINDOW;
+ }
+
+ return Meta.Cursor.CROSSHAIR;
+ }
+
+ stopDrag() {
+ if (!this._dragButton)
+ return;
+
+ if (this._dragGrab) {
+ this._dragGrab.dismiss();
+ this._dragGrab = null;
+ }
+
+ this._dragButton = 0;
+ this._dragSequence = null;
+
+ if (this._dragCursor === Meta.Cursor.CROSSHAIR &&
+ this._lastX === this._startX && this._lastY === this._startY) {
+ // The user clicked without dragging. Make up a larger selection
+ // to reduce confusion.
+ const offset =
+ 20 * St.ThemeContext.get_for_stage(global.stage).scaleFactor;
+ this._startX -= offset;
+ this._startY -= offset;
+ this._lastX += offset;
+ this._lastY += offset;
+
+ // Keep the coordinates inside the stage.
+ if (this._startX < 0) {
+ this._lastX -= this._startX;
+ this._startX = 0;
+ } else if (this._lastX >= this.width) {
+ this._startX -= this._lastX - this.width + 1;
+ this._lastX = this.width - 1;
+ }
+
+ if (this._startY < 0) {
+ this._lastY -= this._startY;
+ this._startY = 0;
+ } else if (this._lastY >= this.height) {
+ this._startY -= this._lastY - this.height + 1;
+ this._lastY = this.height - 1;
+ }
+
+ this._updateSelectionRect();
+ }
+
+ this.emit('drag-ended');
+ }
+
+ _updateCursor(x, y) {
+ const cursor = this._computeCursorType(x, y);
+ global.display.set_cursor(cursor);
+ }
+
+ _onPress(event, button, sequence) {
+ if (this._dragButton)
+ return Clutter.EVENT_PROPAGATE;
+
+ const cursor = this._computeCursorType(event.x, event.y);
+
+ // Clicking outside of the selection, or using the right mouse button,
+ // or with Ctrl results in dragging a new selection from scratch.
+ if (cursor === Meta.Cursor.CROSSHAIR ||
+ button === Clutter.BUTTON_SECONDARY ||
+ (event.modifier_state & Clutter.ModifierType.CONTROL_MASK)) {
+ this._dragButton = button;
+
+ this._dragCursor = Meta.Cursor.CROSSHAIR;
+ global.display.set_cursor(Meta.Cursor.CROSSHAIR);
+
+ [this._startX, this._startY] = [event.x, event.y];
+ this._lastX = this._startX = Math.floor(this._startX);
+ this._lastY = this._startY = Math.floor(this._startY);
+
+ this._updateSelectionRect();
+ } else {
+ // This is a move or resize operation.
+ this._dragButton = button;
+
+ this._dragCursor = cursor;
+ this._dragStartX = event.x;
+ this._dragStartY = event.y;
+
+ const [leftX, topY, width, height] = this.getGeometry();
+ const rightX = leftX + width - 1;
+ const bottomY = topY + height - 1;
+
+ // For moving, start X and Y are the top left corner, while
+ // last X and Y are the bottom right corner.
+ if (cursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) {
+ this._startX = leftX;
+ this._startY = topY;
+ this._lastX = rightX;
+ this._lastY = bottomY;
+ }
+
+ // Start X and Y are set to the stationary sides, while last X
+ // and Y are set to the moving sides.
+ if (cursor === Meta.Cursor.NW_RESIZE ||
+ cursor === Meta.Cursor.WEST_RESIZE ||
+ cursor === Meta.Cursor.SW_RESIZE) {
+ this._startX = rightX;
+ this._lastX = leftX;
+ }
+ if (cursor === Meta.Cursor.NE_RESIZE ||
+ cursor === Meta.Cursor.EAST_RESIZE ||
+ cursor === Meta.Cursor.SE_RESIZE) {
+ this._startX = leftX;
+ this._lastX = rightX;
+ }
+ if (cursor === Meta.Cursor.NW_RESIZE ||
+ cursor === Meta.Cursor.NORTH_RESIZE ||
+ cursor === Meta.Cursor.NE_RESIZE) {
+ this._startY = bottomY;
+ this._lastY = topY;
+ }
+ if (cursor === Meta.Cursor.SW_RESIZE ||
+ cursor === Meta.Cursor.SOUTH_RESIZE ||
+ cursor === Meta.Cursor.SE_RESIZE) {
+ this._startY = topY;
+ this._lastY = bottomY;
+ }
+ }
+
+ if (this._dragButton) {
+ this._dragGrab = global.stage.grab(this);
+ this._dragSequence = sequence;
+
+ this.emit('drag-started');
+
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onRelease(event, button, sequence) {
+ if (this._dragButton !== button ||
+ this._dragSequence?.get_slot() !== sequence?.get_slot())
+ return Clutter.EVENT_PROPAGATE;
+
+ this.stopDrag();
+
+ // We might have finished creating a new selection, so we need to
+ // update the cursor.
+ this._updateCursor(event.x, event.y);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _onMotion(event, sequence) {
+ if (!this._dragButton) {
+ this._updateCursor(event.x, event.y);
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ if (sequence?.get_slot() !== this._dragSequence?.get_slot())
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._dragCursor === Meta.Cursor.CROSSHAIR) {
+ [this._lastX, this._lastY] = [event.x, event.y];
+ this._lastX = Math.floor(this._lastX);
+ this._lastY = Math.floor(this._lastY);
+ } else {
+ let dx = Math.round(event.x - this._dragStartX);
+ let dy = Math.round(event.y - this._dragStartY);
+
+ if (this._dragCursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) {
+ const [,, selectionWidth, selectionHeight] = this.getGeometry();
+
+ let newStartX = this._startX + dx;
+ let newStartY = this._startY + dy;
+ let newLastX = this._lastX + dx;
+ let newLastY = this._lastY + dy;
+
+ let overshootX = 0;
+ let overshootY = 0;
+
+ // Keep the size intact if we bumped into the stage edge.
+ if (newStartX < 0) {
+ overshootX = 0 - newStartX;
+ newStartX = 0;
+ newLastX = newStartX + (selectionWidth - 1);
+ } else if (newLastX > this.width - 1) {
+ overshootX = (this.width - 1) - newLastX;
+ newLastX = this.width - 1;
+ newStartX = newLastX - (selectionWidth - 1);
+ }
+
+ if (newStartY < 0) {
+ overshootY = 0 - newStartY;
+ newStartY = 0;
+ newLastY = newStartY + (selectionHeight - 1);
+ } else if (newLastY > this.height - 1) {
+ overshootY = (this.height - 1) - newLastY;
+ newLastY = this.height - 1;
+ newStartY = newLastY - (selectionHeight - 1);
+ }
+
+ // Add the overshoot to the delta to create a "rubberbanding"
+ // behavior of the pointer when dragging.
+ dx += overshootX;
+ dy += overshootY;
+
+ this._startX = newStartX;
+ this._startY = newStartY;
+ this._lastX = newLastX;
+ this._lastY = newLastY;
+ } else {
+ if (this._dragCursor === Meta.Cursor.WEST_RESIZE ||
+ this._dragCursor === Meta.Cursor.EAST_RESIZE)
+ dy = 0;
+ if (this._dragCursor === Meta.Cursor.NORTH_RESIZE ||
+ this._dragCursor === Meta.Cursor.SOUTH_RESIZE)
+ dx = 0;
+
+ // Make sure last X and Y are clamped between 0 and size - 1,
+ // while always preserving the cursor dragging position relative
+ // to the selection rectangle.
+ this._lastX += dx;
+ if (this._lastX >= this.width) {
+ dx -= this._lastX - this.width + 1;
+ this._lastX = this.width - 1;
+ } else if (this._lastX < 0) {
+ dx -= this._lastX;
+ this._lastX = 0;
+ }
+
+ this._lastY += dy;
+ if (this._lastY >= this.height) {
+ dy -= this._lastY - this.height + 1;
+ this._lastY = this.height - 1;
+ } else if (this._lastY < 0) {
+ dy -= this._lastY;
+ this._lastY = 0;
+ }
+
+ // If we drag the handle past a selection side, update which
+ // handles are which.
+ if (this._lastX > this._startX) {
+ if (this._dragCursor === Meta.Cursor.NW_RESIZE)
+ this._dragCursor = Meta.Cursor.NE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SW_RESIZE)
+ this._dragCursor = Meta.Cursor.SE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.WEST_RESIZE)
+ this._dragCursor = Meta.Cursor.EAST_RESIZE;
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._dragCursor === Meta.Cursor.NE_RESIZE)
+ this._dragCursor = Meta.Cursor.NW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SE_RESIZE)
+ this._dragCursor = Meta.Cursor.SW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.EAST_RESIZE)
+ this._dragCursor = Meta.Cursor.WEST_RESIZE;
+ }
+
+ if (this._lastY > this._startY) {
+ if (this._dragCursor === Meta.Cursor.NW_RESIZE)
+ this._dragCursor = Meta.Cursor.SW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.NE_RESIZE)
+ this._dragCursor = Meta.Cursor.SE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.NORTH_RESIZE)
+ this._dragCursor = Meta.Cursor.SOUTH_RESIZE;
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._dragCursor === Meta.Cursor.SW_RESIZE)
+ this._dragCursor = Meta.Cursor.NW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SE_RESIZE)
+ this._dragCursor = Meta.Cursor.NE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SOUTH_RESIZE)
+ this._dragCursor = Meta.Cursor.NORTH_RESIZE;
+ }
+
+ global.display.set_cursor(this._dragCursor);
+ }
+
+ this._dragStartX += dx;
+ this._dragStartY += dy;
+ }
+
+ this._updateSelectionRect();
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_button_press_event(event) {
+ if (event.button === Clutter.BUTTON_PRIMARY ||
+ event.button === Clutter.BUTTON_SECONDARY)
+ return this._onPress(event, event.button, null);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_release_event(event) {
+ if (event.button === Clutter.BUTTON_PRIMARY ||
+ event.button === Clutter.BUTTON_SECONDARY)
+ return this._onRelease(event, event.button, null);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_motion_event(event) {
+ return this._onMotion(event, null);
+ }
+
+ vfunc_touch_event(event) {
+ if (event.type === Clutter.EventType.TOUCH_BEGIN)
+ return this._onPress(event, 'touch', event.sequence);
+ else if (event.type === Clutter.EventType.TOUCH_END)
+ return this._onRelease(event, 'touch', event.sequence);
+ else if (event.type === Clutter.EventType.TOUCH_UPDATE)
+ return this._onMotion(event, event.sequence);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_leave_event(event) {
+ // If we're dragging and go over the panel we still get a leave event
+ // for some reason, even though we have a grab. We don't want to switch
+ // the cursor when we're dragging.
+ if (!this._dragButton)
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ return super.vfunc_leave_event(event);
+ }
+});
+
+var UIWindowSelectorLayout = GObject.registerClass(
+class UIWindowSelectorLayout extends Workspace.WorkspaceLayout {
+ _init(monitorIndex) {
+ super._init(null, monitorIndex, null);
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ this._syncWorkareaTracking();
+ }
+
+ vfunc_allocate(container, box) {
+ const containerBox = container.allocation;
+ const containerAllocationChanged =
+ this._lastBox === null || !this._lastBox.equal(containerBox);
+ this._lastBox = containerBox.copy();
+
+ let layoutChanged = false;
+ if (this._layout === null) {
+ this._layout = this._createBestLayout(this._workarea);
+ layoutChanged = true;
+ }
+
+ if (layoutChanged || containerAllocationChanged)
+ this._windowSlots = this._getWindowSlots(box.copy());
+
+ const childBox = new Clutter.ActorBox();
+
+ const nSlots = this._windowSlots.length;
+ for (let i = 0; i < nSlots; i++) {
+ let [x, y, width, height, child] = this._windowSlots[i];
+
+ childBox.set_origin(x, y);
+ childBox.set_size(width, height);
+
+ child.allocate(childBox);
+ }
+ }
+
+ addWindow(window) {
+ if (this._sortedWindows.includes(window))
+ return;
+
+ this._sortedWindows.push(window);
+
+ this._container.add_child(window);
+
+ this._layout = null;
+ this.layout_changed();
+ }
+
+ reset() {
+ for (const window of this._sortedWindows)
+ window.destroy();
+
+ this._sortedWindows = [];
+ this._windowSlots = [];
+ this._layout = null;
+ }
+
+ get windows() {
+ return this._sortedWindows;
+ }
+});
+
+var UIWindowSelectorWindow = GObject.registerClass(
+class UIWindowSelectorWindow extends St.Button {
+ _init(actor, params) {
+ super._init(params);
+
+ const window = actor.metaWindow;
+ this._boundingBox = window.get_frame_rect();
+ this._bufferRect = window.get_buffer_rect();
+ this._bufferScale = actor.get_resource_scale();
+ this._actor = new Clutter.Actor({
+ content: actor.paint_to_content(null),
+ });
+ this.add_child(this._actor);
+
+ this._border = new St.Bin({ style_class: 'screenshot-ui-window-selector-window-border' });
+ this._border.connect('style-changed', () => {
+ this._borderSize =
+ this._border.get_theme_node().get_border_width(St.Side.TOP);
+ });
+ this.add_child(this._border);
+
+ this._border.child = new St.Icon({
+ icon_name: 'object-select-symbolic',
+ style_class: 'screenshot-ui-window-selector-check',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._cursor = null;
+ this._cursorPoint = { x: 0, y: 0 };
+ this._shouldShowCursor = window.has_pointer && window.has_pointer();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ get boundingBox() {
+ return this._boundingBox;
+ }
+
+ get windowCenter() {
+ const boundingBox = this.boundingBox;
+ return {
+ x: boundingBox.x + boundingBox.width / 2,
+ y: boundingBox.y + boundingBox.height / 2,
+ };
+ }
+
+ chromeHeights() {
+ return [0, 0];
+ }
+
+ chromeWidths() {
+ return [0, 0];
+ }
+
+ overlapHeights() {
+ return [0, 0];
+ }
+
+ get cursorPoint() {
+ return {
+ x: this._cursorPoint.x + this._boundingBox.x - this._bufferRect.x,
+ y: this._cursorPoint.y + this._boundingBox.y - this._bufferRect.y,
+ };
+ }
+
+ get bufferScale() {
+ return this._bufferScale;
+ }
+
+ get windowContent() {
+ return this._actor.content;
+ }
+
+ _onDestroy() {
+ this.remove_child(this._actor);
+ this._actor.destroy();
+ this._actor = null;
+ this.remove_child(this._border);
+ this._border.destroy();
+ this._border = null;
+
+ if (this._cursor) {
+ this.remove_child(this._cursor);
+ this._cursor.destroy();
+ this._cursor = null;
+ }
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ // Border goes around the window.
+ const borderBox = box.copy();
+ borderBox.set_origin(0, 0);
+ borderBox.x1 -= this._borderSize;
+ borderBox.y1 -= this._borderSize;
+ borderBox.x2 += this._borderSize;
+ borderBox.y2 += this._borderSize;
+ this._border.allocate(borderBox);
+
+ // box should contain this._boundingBox worth of window. Compute
+ // origin and size for the actor box to satisfy that.
+ const xScale = box.get_width() / this._boundingBox.width;
+ const yScale = box.get_height() / this._boundingBox.height;
+
+ const [, windowW, windowH] = this._actor.content.get_preferred_size();
+
+ const actorBox = new Clutter.ActorBox();
+ actorBox.set_origin(
+ (this._bufferRect.x - this._boundingBox.x) * xScale,
+ (this._bufferRect.y - this._boundingBox.y) * yScale
+ );
+ actorBox.set_size(
+ windowW * xScale / this._bufferScale,
+ windowH * yScale / this._bufferScale
+ );
+ this._actor.allocate(actorBox);
+
+ // Allocate the cursor if we have one.
+ if (!this._cursor)
+ return;
+
+ let [, , w, h] = this._cursor.get_preferred_size();
+ w *= this._cursorScale;
+ h *= this._cursorScale;
+
+ const cursorBox = new Clutter.ActorBox({
+ x1: this._cursorPoint.x,
+ y1: this._cursorPoint.y,
+ x2: this._cursorPoint.x + w,
+ y2: this._cursorPoint.y + h,
+ });
+ cursorBox.x1 *= xScale;
+ cursorBox.x2 *= xScale;
+ cursorBox.y1 *= yScale;
+ cursorBox.y2 *= yScale;
+
+ this._cursor.allocate(cursorBox);
+ }
+
+ addCursorTexture(content, point, scale) {
+ if (!this._shouldShowCursor)
+ return;
+
+ // Add the cursor.
+ this._cursor = new St.Widget({
+ content,
+ request_mode: Clutter.RequestMode.CONTENT_SIZE,
+ });
+
+ this._cursorPoint = {
+ x: point.x - this._boundingBox.x,
+ y: point.y - this._boundingBox.y,
+ };
+ this._cursorScale = scale;
+
+ this.insert_child_below(this._cursor, this._border);
+ }
+
+ getCursorTexture() {
+ return this._cursor?.content;
+ }
+
+ setCursorVisible(visible) {
+ if (!this._cursor)
+ return;
+
+ this._cursor.visible = visible;
+ }
+});
+
+var UIWindowSelector = GObject.registerClass(
+class UIWindowSelector extends St.Widget {
+ _init(monitorIndex, params) {
+ super._init(params);
+ super.layout_manager = new Clutter.BinLayout();
+
+ this._monitorIndex = monitorIndex;
+
+ this._layoutManager = new UIWindowSelectorLayout(monitorIndex);
+
+ // Window screenshots
+ this._container = new St.Widget({
+ style_class: 'screenshot-ui-window-selector-window-container',
+ x_expand: true,
+ y_expand: true,
+ });
+ this._container.layout_manager = this._layoutManager;
+ this.add_child(this._container);
+ }
+
+ capture() {
+ for (const actor of global.get_window_actors()) {
+ let window = actor.metaWindow;
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspace = workspaceManager.get_active_workspace();
+ if (window.is_override_redirect() ||
+ !window.located_on_workspace(activeWorkspace) ||
+ window.get_monitor() !== this._monitorIndex)
+ continue;
+
+ const widget = new UIWindowSelectorWindow(
+ actor,
+ {
+ style_class: 'screenshot-ui-window-selector-window',
+ reactive: true,
+ can_focus: true,
+ toggle_mode: true,
+ }
+ );
+
+ widget.connect('key-focus-in', win => {
+ Main.screenshotUI.grab_key_focus();
+ win.checked = true;
+ });
+
+ if (window.has_focus()) {
+ widget.checked = true;
+ widget.toggle_mode = false;
+ }
+
+ this._layoutManager.addWindow(widget);
+ }
+ }
+
+ reset() {
+ this._layoutManager.reset();
+ }
+
+ windows() {
+ return this._layoutManager.windows;
+ }
+});
+
+const UIMode = {
+ SCREENSHOT: 0,
+ SCREENCAST: 1,
+};
+
+var ScreenshotUI = GObject.registerClass({
+ Properties: {
+ 'screencast-in-progress': GObject.ParamSpec.boolean(
+ 'screencast-in-progress',
+ 'screencast-in-progress',
+ 'screencast-in-progress',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+}, class ScreenshotUI extends St.Widget {
+ _init() {
+ super._init({
+ name: 'screenshot-ui',
+ constraints: new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }),
+ layout_manager: new Clutter.BinLayout(),
+ opacity: 0,
+ visible: false,
+ reactive: true,
+ });
+
+ this._screencastInProgress = false;
+ this._screencastSupported = false;
+
+ this._screencastProxy = new ScreencastProxy(
+ Gio.DBus.session,
+ 'org.gnome.Shell.Screencast',
+ '/org/gnome/Shell/Screencast',
+ (object, error) => {
+ if (error !== null) {
+ log('Error connecting to the screencast service');
+ return;
+ }
+
+ this._screencastSupported = this._screencastProxy.ScreencastSupported;
+ this._castButton.visible = this._screencastSupported;
+ });
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+
+ // The full-screen screenshot has a separate container so that we can
+ // show it without the screenshot UI fade-in for a nicer animation.
+ this._stageScreenshotContainer = new St.Widget({ visible: false });
+ this._stageScreenshotContainer.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ Main.layoutManager.screenshotUIGroup.add_child(
+ this._stageScreenshotContainer);
+
+ this._screencastAreaIndicator = new UIAreaIndicator({
+ style_class: 'screenshot-ui-screencast-area-indicator',
+ visible: false,
+ });
+ this._screencastAreaIndicator.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this.bind_property(
+ 'screencast-in-progress',
+ this._screencastAreaIndicator,
+ 'visible',
+ GObject.BindingFlags.DEFAULT);
+ // Add it directly to the stage so that it's above popup menus.
+ global.stage.add_child(this._screencastAreaIndicator);
+ Shell.util_set_hidden_from_pick(this._screencastAreaIndicator, true);
+
+ Main.layoutManager.screenshotUIGroup.add_child(this);
+
+ this._stageScreenshot = new St.Widget({ style_class: 'screenshot-ui-screen-screenshot' });
+ this._stageScreenshot.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this._stageScreenshotContainer.add_child(this._stageScreenshot);
+
+ this._cursor = new St.Widget();
+ this._stageScreenshotContainer.add_child(this._cursor);
+
+ this._openingCoroutineInProgress = false;
+ this._grabHelper = new GrabHelper.GrabHelper(this, {
+ actionMode: Shell.ActionMode.POPUP,
+ });
+
+ this._areaSelector = new UIAreaSelector({
+ style_class: 'screenshot-ui-area-selector',
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+ this.add_child(this._areaSelector);
+
+ this._primaryMonitorBin = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+ this._primaryMonitorBin.add_constraint(
+ new Layout.MonitorConstraint({ 'primary': true }));
+ this.add_child(this._primaryMonitorBin);
+
+ this._panel = new St.BoxLayout({
+ style_class: 'screenshot-ui-panel',
+ y_align: Clutter.ActorAlign.END,
+ y_expand: true,
+ vertical: true,
+ offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY,
+ });
+ this._primaryMonitorBin.add_child(this._panel);
+
+ this._closeButton = new St.Button({
+ style_class: 'screenshot-ui-close-button',
+ icon_name: 'preview-close-symbolic',
+ });
+ this._closeButton.add_constraint(new Clutter.BindConstraint({
+ source: this._panel,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._closeButton.add_constraint(new Clutter.AlignConstraint({
+ source: this._panel,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
+ factor: 0,
+ }));
+ this._closeButtonXAlignConstraint = new Clutter.AlignConstraint({
+ source: this._panel,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ pivot_point: new Graphene.Point({ x: 0.5, y: -1 }),
+ });
+ this._closeButton.add_constraint(this._closeButtonXAlignConstraint);
+ this._closeButton.connect('clicked', () => this.close());
+ this._primaryMonitorBin.add_child(this._closeButton);
+
+ this._areaSelector.connect('drag-started', () => {
+ this._panel.ease({
+ opacity: 100,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this._closeButton.ease({
+ opacity: 100,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+ this._areaSelector.connect('drag-ended', () => {
+ this._panel.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this._closeButton.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+
+ this._typeButtonContainer = new St.Widget({
+ style_class: 'screenshot-ui-type-button-container',
+ layout_manager: new Clutter.BoxLayout({
+ spacing: 12,
+ homogeneous: true,
+ }),
+ });
+ this._panel.add_child(this._typeButtonContainer);
+
+ this._selectionButton = new IconLabelButton('screenshot-ui-area-symbolic', _('Selection'), {
+ style_class: 'screenshot-ui-type-button',
+ checked: true,
+ x_expand: true,
+ });
+ this._selectionButton.connect('notify::checked',
+ this._onSelectionButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._selectionButton);
+
+ this.add_child(new Tooltip(this._selectionButton, {
+ text: _('Area Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._screenButton = new IconLabelButton('screenshot-ui-display-symbolic', _('Screen'), {
+ style_class: 'screenshot-ui-type-button',
+ toggle_mode: true,
+ x_expand: true,
+ });
+ this._screenButton.connect('notify::checked',
+ this._onScreenButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._screenButton);
+
+ this.add_child(new Tooltip(this._screenButton, {
+ text: _('Screen Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._windowButton = new IconLabelButton('screenshot-ui-window-symbolic', _('Window'), {
+ style_class: 'screenshot-ui-type-button',
+ toggle_mode: true,
+ x_expand: true,
+ });
+ this._windowButton.connect('notify::checked',
+ this._onWindowButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._windowButton);
+
+ this.add_child(new Tooltip(this._windowButton, {
+ text: _('Window Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._bottomRowContainer = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+ this._panel.add_child(this._bottomRowContainer);
+
+ this._shotCastContainer = new St.BoxLayout({
+ style_class: 'screenshot-ui-shot-cast-container',
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ });
+ this._bottomRowContainer.add_child(this._shotCastContainer);
+
+ this._shotButton = new St.Button({
+ style_class: 'screenshot-ui-shot-cast-button',
+ icon_name: 'camera-photo-symbolic',
+ checked: true,
+ });
+ this._shotButton.connect('notify::checked',
+ this._onShotButtonToggled.bind(this));
+ this._shotCastContainer.add_child(this._shotButton);
+
+ this._castButton = new St.Button({
+ style_class: 'screenshot-ui-shot-cast-button',
+ icon_name: 'camera-web-symbolic',
+ toggle_mode: true,
+ visible: false,
+ });
+ this._castButton.connect('notify::checked',
+ this._onCastButtonToggled.bind(this));
+ this._shotCastContainer.add_child(this._castButton);
+
+ this._shotButton.bind_property('checked', this._castButton, 'checked',
+ GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this._shotCastTooltip = new Tooltip(this._shotCastContainer, {
+ text: _('Screenshot / Screencast'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ });
+ const shotCastCallback = () => {
+ if (this._shotButton.hover || this._castButton.hover)
+ this._shotCastTooltip.open();
+ else
+ this._shotCastTooltip.close();
+ };
+ this._shotButton.connect('notify::hover', shotCastCallback);
+ this._castButton.connect('notify::hover', shotCastCallback);
+ this.add_child(this._shotCastTooltip);
+
+ this._captureButton = new St.Button({ style_class: 'screenshot-ui-capture-button' });
+ this._captureButton.set_child(new St.Widget({
+ style_class: 'screenshot-ui-capture-button-circle',
+ }));
+ this._captureButton.connect('clicked',
+ this._onCaptureButtonClicked.bind(this));
+ this._bottomRowContainer.add_child(this._captureButton);
+
+ this._showPointerButtonContainer = new St.BoxLayout({
+ x_align: Clutter.ActorAlign.END,
+ x_expand: true,
+ });
+ this._bottomRowContainer.add_child(this._showPointerButtonContainer);
+
+ this._showPointerButton = new St.Button({
+ style_class: 'screenshot-ui-show-pointer-button',
+ icon_name: 'screenshot-ui-show-pointer-symbolic',
+ toggle_mode: true,
+ });
+ this._showPointerButtonContainer.add_child(this._showPointerButton);
+
+ this.add_child(new Tooltip(this._showPointerButton, {
+ text: _('Show Pointer'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._showPointerButton.connect('notify::checked', () => {
+ const state = this._showPointerButton.checked;
+ this._cursor.visible = state;
+
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+ for (const window of windows)
+ window.setCursorVisible(state);
+ });
+ this._cursor.visible = false;
+
+ this._monitorBins = [];
+ this._windowSelectors = [];
+ this._rebuildMonitorBins();
+
+ Main.layoutManager.connect('monitors-changed', () => {
+ // Nope, not dealing with monitor changes.
+ this.close(true);
+ this._rebuildMonitorBins();
+ });
+
+ const uiModes =
+ Shell.ActionMode.ALL & ~Shell.ActionMode.LOGIN_SCREEN;
+ const restrictedModes =
+ uiModes &
+ ~(Shell.ActionMode.LOCK_SCREEN | Shell.ActionMode.UNLOCK_SCREEN);
+
+ Main.wm.addKeybinding(
+ 'show-screenshot-ui',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ uiModes,
+ showScreenshotUI
+ );
+
+ Main.wm.addKeybinding(
+ 'show-screen-recording-ui',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ restrictedModes,
+ showScreenRecordingUI
+ );
+
+ Main.wm.addKeybinding(
+ 'screenshot-window',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT | Meta.KeyBindingFlags.PER_WINDOW,
+ restrictedModes,
+ async (_display, window, _binding) => {
+ try {
+ const actor = window.get_compositor_private();
+ const content = actor.paint_to_content(null);
+ const texture = content.get_texture();
+
+ await captureScreenshot(texture, null, 1, null);
+ } catch (e) {
+ logError(e, 'Error capturing screenshot');
+ }
+ }
+ );
+
+ Main.wm.addKeybinding(
+ 'screenshot',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ uiModes,
+ async () => {
+ try {
+ const shooter = new Shell.Screenshot();
+ const [content] = await shooter.screenshot_stage_to_content();
+ const texture = content.get_texture();
+
+ await captureScreenshot(texture, null, 1, null);
+ } catch (e) {
+ logError(e, 'Error capturing screenshot');
+ }
+ }
+ );
+
+ Main.sessionMode.connect('updated',
+ () => this._sessionUpdated());
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ this.close(true);
+ this._castButton.reactive = Main.sessionMode.allowScreencast;
+ }
+
+ _syncWindowButtonSensitivity() {
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+
+ this._windowButton.reactive =
+ Main.sessionMode.hasWindows &&
+ windows.length > 0 &&
+ !this._castButton.checked;
+ }
+
+ _refreshButtonLayout() {
+ const buttonLayout = Meta.prefs_get_button_layout();
+
+ this._closeButton.remove_style_class_name('left');
+ this._closeButton.remove_style_class_name('right');
+
+ if (buttonLayout.left_buttons.includes(Meta.ButtonFunction.CLOSE)) {
+ this._closeButton.add_style_class_name('left');
+ this._closeButtonXAlignConstraint.factor = 0;
+ } else {
+ this._closeButton.add_style_class_name('right');
+ this._closeButtonXAlignConstraint.factor = 1;
+ }
+ }
+
+ _rebuildMonitorBins() {
+ for (const bin of this._monitorBins)
+ bin.destroy();
+
+ this._monitorBins = [];
+ this._windowSelectors = [];
+ this._screenSelectors = [];
+
+ for (let i = 0; i < Main.layoutManager.monitors.length; i++) {
+ const bin = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ });
+ bin.add_constraint(new Layout.MonitorConstraint({ 'index': i }));
+ this.insert_child_below(bin, this._primaryMonitorBin);
+ this._monitorBins.push(bin);
+
+ const windowSelector = new UIWindowSelector(i, {
+ style_class: 'screenshot-ui-window-selector',
+ x_expand: true,
+ y_expand: true,
+ visible: this._windowButton.checked,
+ });
+ if (i === Main.layoutManager.primaryIndex)
+ windowSelector.add_style_pseudo_class('primary-monitor');
+
+ bin.add_child(windowSelector);
+ this._windowSelectors.push(windowSelector);
+
+ const screenSelector = new St.Button({
+ style_class: 'screenshot-ui-screen-selector',
+ x_expand: true,
+ y_expand: true,
+ visible: this._screenButton.checked,
+ reactive: true,
+ can_focus: true,
+ toggle_mode: true,
+ });
+ screenSelector.connect('key-focus-in', () => {
+ this.grab_key_focus();
+ screenSelector.checked = true;
+ });
+ bin.add_child(screenSelector);
+ this._screenSelectors.push(screenSelector);
+
+ screenSelector.connect('notify::checked', () => {
+ if (!screenSelector.checked)
+ return;
+
+ screenSelector.toggle_mode = false;
+
+ for (const otherSelector of this._screenSelectors) {
+ if (screenSelector === otherSelector)
+ continue;
+
+ otherSelector.toggle_mode = true;
+ otherSelector.checked = false;
+ }
+ });
+ }
+
+ if (Main.layoutManager.primaryIndex !== -1)
+ this._screenSelectors[Main.layoutManager.primaryIndex].checked = true;
+ }
+
+ async open(mode = UIMode.SCREENSHOT) {
+ if (this._openingCoroutineInProgress)
+ return;
+
+ if (this._screencastInProgress)
+ return;
+
+ if (mode === UIMode.SCREENCAST && !this._screencastSupported)
+ return;
+
+ this._castButton.checked = mode === UIMode.SCREENCAST;
+
+ if (!this.visible) {
+ // Screenshot UI is opening from completely closed state
+ // (rather than opening back from in process of closing).
+ for (const selector of this._windowSelectors)
+ selector.capture();
+
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+ for (const window of windows) {
+ window.connect('notify::checked', () => {
+ if (!window.checked)
+ return;
+
+ window.toggle_mode = false;
+
+ for (const otherWindow of windows) {
+ if (window === otherWindow)
+ continue;
+
+ otherWindow.toggle_mode = true;
+ otherWindow.checked = false;
+ }
+ });
+ }
+
+ this._syncWindowButtonSensitivity();
+ if (!this._windowButton.reactive)
+ this._selectionButton.checked = true;
+
+ this._shooter = new Shell.Screenshot();
+
+ this._openingCoroutineInProgress = true;
+ try {
+ const [content, scale, cursorContent, cursorPoint, cursorScale] =
+ await this._shooter.screenshot_stage_to_content();
+ this._stageScreenshot.set_content(content);
+ this._scale = scale;
+
+ if (cursorContent !== null) {
+ this._cursor.set_content(cursorContent);
+ this._cursor.set_position(cursorPoint.x, cursorPoint.y);
+
+ let [, w, h] = cursorContent.get_preferred_size();
+ w *= cursorScale;
+ h *= cursorScale;
+ this._cursor.set_size(w, h);
+
+ this._cursorScale = cursorScale;
+
+ for (const window of windows) {
+ window.addCursorTexture(cursorContent, cursorPoint, cursorScale);
+ window.setCursorVisible(this._showPointerButton.checked);
+ }
+ }
+
+ this._stageScreenshotContainer.show();
+ } catch (e) {
+ log(`Error capturing screenshot: ${e.message}`);
+ }
+ this._openingCoroutineInProgress = false;
+ }
+
+ // Get rid of any popup menus.
+ // We already have them captured on the screenshot anyway.
+ //
+ // This needs to happen before the grab below as closing menus will
+ // pop their grabs.
+ Main.layoutManager.emit('system-modal-opened');
+
+ const { screenshotUIGroup } = Main.layoutManager;
+ screenshotUIGroup.get_parent().set_child_above_sibling(
+ screenshotUIGroup, null);
+
+ const grabResult = this._grabHelper.grab({
+ actor: this,
+ onUngrab: () => this.close(),
+ });
+ if (!grabResult) {
+ this.close(true);
+ return;
+ }
+
+ this._refreshButtonLayout();
+
+ this.remove_all_transitions();
+ this.visible = true;
+ this.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._stageScreenshotContainer.get_parent().remove_child(
+ this._stageScreenshotContainer);
+ this.insert_child_at_index(this._stageScreenshotContainer, 0);
+ },
+ });
+ }
+
+ _finishClosing() {
+ this.hide();
+
+ this._shooter = null;
+
+ // Switch back to screenshot mode.
+ this._shotButton.checked = true;
+
+ this._stageScreenshotContainer.get_parent().remove_child(
+ this._stageScreenshotContainer);
+ Main.layoutManager.screenshotUIGroup.insert_child_at_index(
+ this._stageScreenshotContainer, 0);
+ this._stageScreenshotContainer.hide();
+
+ this._stageScreenshot.set_content(null);
+ this._cursor.set_content(null);
+
+ this._areaSelector.reset();
+ for (const selector of this._windowSelectors)
+ selector.reset();
+ }
+
+ close(instantly = false) {
+ this._grabHelper.ungrab();
+
+ if (instantly) {
+ this._finishClosing();
+ return;
+ }
+
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: this._finishClosing.bind(this),
+ });
+ }
+
+ _onSelectionButtonToggled() {
+ if (this._selectionButton.checked) {
+ this._selectionButton.toggle_mode = false;
+ this._windowButton.checked = false;
+ this._screenButton.checked = false;
+
+ this._areaSelector.show();
+ this._areaSelector.remove_all_transitions();
+ this._areaSelector.reactive = true;
+ this._areaSelector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ this._selectionButton.toggle_mode = true;
+
+ this._areaSelector.stopDrag();
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ this._areaSelector.remove_all_transitions();
+ this._areaSelector.reactive = false;
+ this._areaSelector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._areaSelector.hide(),
+ });
+ }
+ }
+
+ _onScreenButtonToggled() {
+ if (this._screenButton.checked) {
+ this._screenButton.toggle_mode = false;
+ this._selectionButton.checked = false;
+ this._windowButton.checked = false;
+
+ for (const selector of this._screenSelectors) {
+ selector.show();
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ } else {
+ this._screenButton.toggle_mode = true;
+
+ for (const selector of this._screenSelectors) {
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => selector.hide(),
+ });
+ }
+ }
+ }
+
+ _onWindowButtonToggled() {
+ if (this._windowButton.checked) {
+ this._windowButton.toggle_mode = false;
+ this._selectionButton.checked = false;
+ this._screenButton.checked = false;
+
+ for (const selector of this._windowSelectors) {
+ selector.show();
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ } else {
+ this._windowButton.toggle_mode = true;
+
+ for (const selector of this._windowSelectors) {
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => selector.hide(),
+ });
+ }
+ }
+ }
+
+ _onShotButtonToggled() {
+ if (this._shotButton.checked) {
+ this._shotButton.toggle_mode = false;
+
+ this._stageScreenshotContainer.show();
+ this._stageScreenshotContainer.remove_all_transitions();
+ this._stageScreenshotContainer.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ this._shotButton.toggle_mode = true;
+ }
+ }
+
+ _onCastButtonToggled() {
+ if (this._castButton.checked) {
+ this._castButton.toggle_mode = false;
+
+ this._captureButton.add_style_pseudo_class('cast');
+
+ this._stageScreenshotContainer.remove_all_transitions();
+ this._stageScreenshotContainer.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._stageScreenshotContainer.hide(),
+ });
+
+ // Screen recording doesn't support window selection yet.
+ if (this._windowButton.checked)
+ this._selectionButton.checked = true;
+
+ this._windowButton.reactive = false;
+ } else {
+ this._castButton.toggle_mode = true;
+
+ this._captureButton.remove_style_pseudo_class('cast');
+
+ this._syncWindowButtonSensitivity();
+ }
+ }
+
+ _getSelectedGeometry(rescale) {
+ let x, y, w, h;
+
+ if (this._selectionButton.checked) {
+ [x, y, w, h] = this._areaSelector.getGeometry();
+ } else if (this._screenButton.checked) {
+ const index =
+ this._screenSelectors.findIndex(screen => screen.checked);
+ const monitor = Main.layoutManager.monitors[index];
+
+ x = monitor.x;
+ y = monitor.y;
+ w = monitor.width;
+ h = monitor.height;
+ }
+
+ if (rescale) {
+ x *= this._scale;
+ y *= this._scale;
+ w *= this._scale;
+ h *= this._scale;
+ }
+
+ return [x, y, w, h];
+ }
+
+ _onCaptureButtonClicked() {
+ if (this._shotButton.checked) {
+ this._saveScreenshot();
+ this.close();
+ } else {
+ // Screencast closes the UI on its own.
+ this._startScreencast();
+ }
+ }
+
+ _saveScreenshot() {
+ if (this._selectionButton.checked || this._screenButton.checked) {
+ const content = this._stageScreenshot.get_content();
+ if (!content)
+ return; // Failed to capture the screenshot for some reason.
+
+ const texture = content.get_texture();
+ const geometry = this._getSelectedGeometry(true);
+
+ let cursorTexture = this._cursor.content?.get_texture();
+ if (!this._cursor.visible)
+ cursorTexture = null;
+
+ captureScreenshot(
+ texture, geometry, this._scale,
+ {
+ texture: cursorTexture ?? null,
+ x: this._cursor.x * this._scale,
+ y: this._cursor.y * this._scale,
+ scale: this._cursorScale,
+ }
+ ).catch(e => logError(e, 'Error capturing screenshot'));
+ } else if (this._windowButton.checked) {
+ const window =
+ this._windowSelectors.flatMap(selector => selector.windows())
+ .find(win => win.checked);
+ if (!window)
+ return;
+
+ const content = window.windowContent;
+ if (!content)
+ return;
+
+ const texture = content.get_texture();
+
+ let cursorTexture = window.getCursorTexture()?.get_texture();
+ if (!this._cursor.visible)
+ cursorTexture = null;
+
+ captureScreenshot(
+ texture,
+ null,
+ window.bufferScale,
+ {
+ texture: cursorTexture ?? null,
+ x: window.cursorPoint.x * window.bufferScale,
+ y: window.cursorPoint.y * window.bufferScale,
+ scale: this._cursorScale,
+ }
+ ).catch(e => logError(e, 'Error capturing screenshot'));
+ }
+ }
+
+ async _startScreencast() {
+ if (this._windowButton.checked)
+ return; // TODO
+
+ const [x, y, w, h] = this._getSelectedGeometry(false);
+ const drawCursor = this._cursor.visible;
+
+ // Set up the screencast indicator rect.
+ if (this._selectionButton.checked) {
+ this._screencastAreaIndicator.setSelectionRect(
+ ...this._areaSelector.getGeometry());
+ } else if (this._screenButton.checked) {
+ const index =
+ this._screenSelectors.findIndex(screen => screen.checked);
+ const monitor = Main.layoutManager.monitors[index];
+
+ this._screencastAreaIndicator.setSelectionRect(
+ monitor.x, monitor.y, monitor.width, monitor.height);
+ }
+
+ // Close instantly so the fade-out doesn't get recorded.
+ this.close(true);
+
+ // This is a bit awkward because creating a proxy synchronously hangs Shell.
+ let method =
+ this._screencastProxy.ScreencastAsync.bind(this._screencastProxy);
+ if (w !== -1) {
+ method = this._screencastProxy.ScreencastAreaAsync.bind(
+ this._screencastProxy, x, y, w, h);
+ }
+
+ // Set this before calling the method as the screen recording indicator
+ // will check it before the success callback fires.
+ this._setScreencastInProgress(true);
+
+ try {
+ const [success, path] = await method(
+ GLib.build_filenamev([
+ /* Translators: this is the folder where recorded
+ screencasts are stored. */
+ _('Screencasts'),
+ /* Translators: this is a filename used for screencast
+ * recording, where "%d" and "%t" date and time, e.g.
+ * "Screencast from 07-17-2013 10:00:46 PM.webm" */
+ /* xgettext:no-c-format */
+ _('Screencast from %d %t.webm'),
+ ]),
+ {'draw-cursor': new GLib.Variant('b', drawCursor)});
+ if (!success)
+ throw new Error();
+ this._screencastPath = path;
+ } catch (error) {
+ this._setScreencastInProgress(false);
+ const {message} = error;
+ if (message)
+ log(`Error starting screencast: ${message}`);
+ else
+ log('Error starting screencast');
+ }
+ }
+
+ async stopScreencast() {
+ if (!this._screencastInProgress)
+ return;
+
+ // Set this before calling the method as the screen recording indicator
+ // will check it before the success callback fires.
+ this._setScreencastInProgress(false);
+
+ try {
+ const [success] = await this._screencastProxy.StopScreencastAsync();
+ if (!success)
+ throw new Error();
+ } catch (error) {
+ const {message} = error;
+ if (message)
+ log(`Error stopping screencast: ${message}`);
+ else
+ log('Error stopping screencast');
+ return;
+ }
+
+ // Show a notification.
+ const file = Gio.file_new_for_path(this._screencastPath);
+
+ const source = new MessageTray.Source(
+ // Translators: notification source name.
+ _('Screenshot'),
+ 'screencast-recorded-symbolic'
+ );
+ const notification = new MessageTray.Notification(
+ source,
+ // Translators: notification title.
+ _('Screencast recorded'),
+ // Translators: notification body when a screencast was recorded.
+ _('Click here to view the video.')
+ );
+ // Translators: button on the screencast notification.
+ notification.addAction(_('Show in Files'), () => {
+ const app =
+ Gio.app_info_get_default_for_type('inode/directory', false);
+
+ if (app === null) {
+ // It may be null e.g. in a toolbox without nautilus.
+ log('Error showing in files: no default app set for inode/directory');
+ return;
+ }
+
+ app.launch([file], global.create_app_launch_context(0, -1));
+ });
+ notification.connect('activated', () => {
+ try {
+ Gio.app_info_launch_default_for_uri(
+ file.get_uri(), global.create_app_launch_context(0, -1));
+ } catch (err) {
+ logError(err, 'Error opening screencast');
+ }
+ });
+ notification.setTransient(true);
+
+ Main.messageTray.add(source);
+ source.showNotification(notification);
+ }
+
+ get screencast_in_progress() {
+ if (!('_screencastInProgress' in this))
+ return false;
+
+ return this._screencastInProgress;
+ }
+
+ _setScreencastInProgress(inProgress) {
+ if (this._screencastInProgress === inProgress)
+ return;
+
+ this._screencastInProgress = inProgress;
+ this.notify('screencast-in-progress');
+ }
+
+ vfunc_key_press_event(event) {
+ const symbol = event.keyval;
+ if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_space ||
+ ((event.modifier_state & Clutter.ModifierType.CONTROL_MASK) &&
+ (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C))) {
+ this._onCaptureButtonClicked();
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_s || symbol === Clutter.KEY_S) {
+ this._selectionButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C) {
+ this._screenButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (this._windowButton.reactive &&
+ (symbol === Clutter.KEY_w || symbol === Clutter.KEY_W)) {
+ this._windowButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_p || symbol === Clutter.KEY_P) {
+ this._showPointerButton.checked = !this._showPointerButton.checked;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (this._castButton.reactive &&
+ (symbol === Clutter.KEY_v || symbol === Clutter.KEY_V)) {
+ this._castButton.checked = !this._castButton.checked;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_Left || symbol === Clutter.KEY_Right ||
+ symbol === Clutter.KEY_Up || symbol === Clutter.KEY_Down) {
+ let direction;
+ if (symbol === Clutter.KEY_Left)
+ direction = St.DirectionType.LEFT;
+ else if (symbol === Clutter.KEY_Right)
+ direction = St.DirectionType.RIGHT;
+ else if (symbol === Clutter.KEY_Up)
+ direction = St.DirectionType.UP;
+ else if (symbol === Clutter.KEY_Down)
+ direction = St.DirectionType.DOWN;
+
+ if (this._windowButton.checked) {
+ const window =
+ this._windowSelectors.flatMap(selector => selector.windows())
+ .find(win => win.checked) ?? null;
+ this.navigate_focus(window, direction, false);
+ } else if (this._screenButton.checked) {
+ const screen =
+ this._screenSelectors.find(selector => selector.checked) ?? null;
+ this.navigate_focus(screen, direction, false);
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ return super.vfunc_key_press_event(event);
+ }
+});
+
+/**
+ * Stores a PNG-encoded screenshot into the clipboard and a file, and shows a
+ * notification.
+ *
+ * @param {GLib.Bytes} bytes - The PNG-encoded screenshot.
+ * @param {GdkPixbuf.Pixbuf} pixbuf - The Pixbuf with the screenshot.
+ */
+function _storeScreenshot(bytes, pixbuf) {
+ // Store to the clipboard first in case storing to file fails.
+ const clipboard = St.Clipboard.get_default();
+ clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes);
+
+ const time = GLib.DateTime.new_now_local();
+
+ // This will be set in the first save to disk branch and then accessed
+ // in the second save to disk branch, so we need to declare it outside.
+ let file;
+
+ // The function is declared here rather than inside the condition to
+ // satisfy eslint.
+
+ /**
+ * Returns a filename suffix with an increasingly large index.
+ *
+ * @returns {Generator<string|*, void, *>} suffix string
+ */
+ function* suffixes() {
+ yield '';
+
+ for (let i = 1; ; i++)
+ yield `-${i}`;
+ }
+
+ const lockdownSettings =
+ new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+ const disableSaveToDisk =
+ lockdownSettings.get_boolean('disable-save-to-disk');
+
+ if (!disableSaveToDisk) {
+ const dir = Gio.File.new_for_path(GLib.build_filenamev([
+ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) || GLib.get_home_dir(),
+ // Translators: name of the folder under ~/Pictures for screenshots.
+ _('Screenshots'),
+ ]));
+
+ try {
+ dir.make_directory_with_parents(null);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ throw e;
+ }
+
+ const timestamp = time.format('%Y-%m-%d %H-%M-%S');
+ // Translators: this is the name of the file that the screenshot is
+ // saved to. The placeholder is a timestamp, e.g. "2017-05-21 12-24-03".
+ const name = _('Screenshot from %s').format(timestamp);
+
+ // If the target file already exists, try appending a suffix with an
+ // increasing number to it.
+ for (const suffix of suffixes()) {
+ file = Gio.File.new_for_path(GLib.build_filenamev([
+ dir.get_path(), `${name}${suffix}.png`,
+ ]));
+
+ try {
+ const stream = file.create(Gio.FileCreateFlags.NONE, null);
+ stream.write_bytes(bytes, null);
+ break;
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ throw e;
+ }
+ }
+
+ // Add it to recent files.
+ Gtk.RecentManager.get_default().add_item(file.get_uri());
+ }
+
+ // Create a St.ImageContent icon for the notification. We want
+ // St.ImageContent specifically because it preserves the aspect ratio when
+ // shown in a notification.
+ const pixels = pixbuf.read_pixel_bytes();
+ const content =
+ St.ImageContent.new_with_preferred_size(pixbuf.width, pixbuf.height);
+ content.set_bytes(
+ pixels,
+ Cogl.PixelFormat.RGBA_8888,
+ pixbuf.width,
+ pixbuf.height,
+ pixbuf.rowstride
+ );
+
+ // Show a notification.
+ const source = new MessageTray.Source(
+ // Translators: notification source name.
+ _('Screenshot'),
+ 'screenshot-recorded-symbolic'
+ );
+ const notification = new MessageTray.Notification(
+ source,
+ // Translators: notification title.
+ _('Screenshot captured'),
+ // Translators: notification body when a screenshot was captured.
+ _('You can paste the image from the clipboard.'),
+ { datetime: time, gicon: content }
+ );
+
+ if (!disableSaveToDisk) {
+ // Translators: button on the screenshot notification.
+ notification.addAction(_('Show in Files'), () => {
+ const app =
+ Gio.app_info_get_default_for_type('inode/directory', false);
+
+ if (app === null) {
+ // It may be null e.g. in a toolbox without nautilus.
+ log('Error showing in files: no default app set for inode/directory');
+ return;
+ }
+
+ app.launch([file], global.create_app_launch_context(0, -1));
+ });
+ notification.connect('activated', () => {
+ try {
+ Gio.app_info_launch_default_for_uri(
+ file.get_uri(), global.create_app_launch_context(0, -1));
+ } catch (err) {
+ logError(err, 'Error opening screenshot');
+ }
+ });
+ }
+
+ notification.setTransient(true);
+ Main.messageTray.add(source);
+ source.showNotification(notification);
+}
+
+/**
+ * Captures a screenshot from a texture, given a region, scale and optional
+ * cursor data.
+ *
+ * @param {Cogl.Texture} texture - The texture to take the screenshot from.
+ * @param {number[4]} [geometry] - The region to use: x, y, width and height.
+ * @param {number} scale - The texture scale.
+ * @param {Object} [cursor] - Cursor data to include in the screenshot.
+ * @param {Cogl.Texture} cursor.texture - The cursor texture.
+ * @param {number} cursor.x - The cursor x coordinate.
+ * @param {number} cursor.y - The cursor y coordinate.
+ * @param {number} cursor.scale - The cursor texture scale.
+ */
+async function captureScreenshot(texture, geometry, scale, cursor) {
+ const stream = Gio.MemoryOutputStream.new_resizable();
+ const [x, y, w, h] = geometry ?? [0, 0, -1, -1];
+ if (cursor === null)
+ cursor = { texture: null, x: 0, y: 0, scale: 1 };
+
+ global.display.get_sound_player().play_from_theme(
+ 'screen-capture', _('Screenshot taken'), null);
+
+ const pixbuf = await Shell.Screenshot.composite_to_stream(
+ texture,
+ x, y, w, h,
+ scale,
+ cursor.texture, cursor.x, cursor.y, cursor.scale,
+ stream
+ );
+
+ stream.close(null);
+ _storeScreenshot(stream.steal_as_bytes(), pixbuf);
+}
+
+/**
+ * Shows the screenshot UI.
+ */
+function showScreenshotUI() {
+ Main.screenshotUI.open().catch(err => {
+ logError(err, 'Error opening the screenshot UI');
+ });
+}
+
+/**
+ * Shows the screen recording UI.
+ */
+function showScreenRecordingUI() {
+ Main.screenshotUI.open(UIMode.SCREENCAST).catch(err => {
+ logError(err, 'Error opening the screenshot UI');
+ });
+}
+
+var ScreenshotService = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenshotIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Screenshot');
+
+ this._screenShooter = new Map();
+ this._senderChecker = new DBusSenderChecker([
+ 'org.gnome.SettingsDaemon.MediaKeys',
+ 'org.freedesktop.impl.portal.desktop.gtk',
+ 'org.freedesktop.impl.portal.desktop.gnome',
+ 'org.gnome.Screenshot',
+ ]);
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+
+ Gio.DBus.session.own_name('org.gnome.Shell.Screenshot', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ async _createScreenshot(invocation, needsDisk = true, restrictCallers = true) {
+ let lockedDown = false;
+ if (needsDisk)
+ lockedDown = this._lockdownSettings.get_boolean('disable-save-to-disk');
+
+ let sender = invocation.get_sender();
+ if (this._screenShooter.has(sender)) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY,
+ 'There is an ongoing operation for this sender');
+ return null;
+ } else if (lockedDown) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.PERMISSION_DENIED,
+ 'Saving to disk is disabled');
+ return null;
+ } else if (restrictCallers) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return null;
+ }
+ }
+
+ let shooter = new Shell.Screenshot();
+ shooter._watchNameId =
+ Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null,
+ this._onNameVanished.bind(this));
+
+ this._screenShooter.set(sender, shooter);
+
+ return shooter;
+ }
+
+ _onNameVanished(connection, name) {
+ this._removeShooterForSender(name);
+ }
+
+ _removeShooterForSender(sender) {
+ let shooter = this._screenShooter.get(sender);
+ if (!shooter)
+ return;
+
+ Gio.bus_unwatch_name(shooter._watchNameId);
+ this._screenShooter.delete(sender);
+ }
+
+ _checkArea(x, y, width, height) {
+ return x >= 0 && y >= 0 &&
+ width > 0 && height > 0 &&
+ x + width <= global.screen_width &&
+ y + height <= global.screen_height;
+ }
+
+ *_resolveRelativeFilename(filename) {
+ filename = filename.replace(/\.png$/, '');
+
+ let path = [
+ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES),
+ GLib.get_home_dir(),
+ ].find(p => p && GLib.file_test(p, GLib.FileTest.EXISTS));
+
+ if (!path)
+ return null;
+
+ yield Gio.File.new_for_path(
+ GLib.build_filenamev([path, `${filename}.png`]));
+
+ for (let idx = 1; ; idx++) {
+ yield Gio.File.new_for_path(
+ GLib.build_filenamev([path, `${filename}-${idx}.png`]));
+ }
+ }
+
+ _createStream(filename, invocation) {
+ if (filename == '')
+ return [Gio.MemoryOutputStream.new_resizable(), null];
+
+ if (GLib.path_is_absolute(filename)) {
+ try {
+ let file = Gio.File.new_for_path(filename);
+ let stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null);
+ return [stream, file];
+ } catch (e) {
+ invocation.return_gerror(e);
+ this._removeShooterForSender(invocation.get_sender());
+ return [null, null];
+ }
+ }
+
+ let err;
+ for (let file of this._resolveRelativeFilename(filename)) {
+ try {
+ let stream = file.create(Gio.FileCreateFlags.NONE, null);
+ return [stream, file];
+ } catch (e) {
+ err = e;
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ break;
+ }
+ }
+
+ invocation.return_gerror(err);
+ this._removeShooterForSender(invocation.get_sender());
+ return [null, null];
+ }
+
+ _flashAsync(shooter) {
+ return new Promise((resolve, _reject) => {
+ shooter.connect('screenshot_taken', (s, area) => {
+ const flashspot = new Flashspot(area);
+ flashspot.fire(resolve);
+
+ global.display.get_sound_player().play_from_theme(
+ 'screen-capture', _('Screenshot taken'), null);
+ });
+ });
+ }
+
+ _onScreenshotComplete(stream, file, invocation) {
+ stream.close(null);
+
+ let filenameUsed = '';
+ if (file) {
+ filenameUsed = file.get_path();
+ } else {
+ let bytes = stream.steal_as_bytes();
+ let clipboard = St.Clipboard.get_default();
+ clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes);
+ }
+
+ let retval = GLib.Variant.new('(bs)', [true, filenameUsed]);
+ invocation.return_value(retval);
+ }
+
+ _scaleArea(x, y, width, height) {
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ x *= scaleFactor;
+ y *= scaleFactor;
+ width *= scaleFactor;
+ height *= scaleFactor;
+ return [x, y, width, height];
+ }
+
+ _unscaleArea(x, y, width, height) {
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ x /= scaleFactor;
+ y /= scaleFactor;
+ width /= scaleFactor;
+ height /= scaleFactor;
+ return [x, y, width, height];
+ }
+
+ async ScreenshotAreaAsync(params, invocation) {
+ let [x, y, width, height, flash, filename] = params;
+ [x, y, width, height] = this._scaleArea(x, y, width, height);
+ if (!this._checkArea(x, y, width, height)) {
+ invocation.return_error_literal(Gio.IOErrorEnum,
+ Gio.IOErrorEnum.CANCELLED,
+ "Invalid params");
+ return;
+ }
+ let screenshot = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot_area(x, y, width, height, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async ScreenshotWindowAsync(params, invocation) {
+ let [includeFrame, includeCursor, flash, filename] = params;
+ let screenshot = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot_window(includeFrame, includeCursor, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async ScreenshotAsync(params, invocation) {
+ let [includeCursor, flash, filename] = params;
+ let screenshot = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot(includeCursor, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async SelectAreaAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let selectArea = new SelectArea();
+ try {
+ let areaRectangle = await selectArea.selectAsync();
+ let retRectangle = this._unscaleArea(
+ areaRectangle.x, areaRectangle.y,
+ areaRectangle.width, areaRectangle.height);
+ invocation.return_value(GLib.Variant.new('(iiii)', retRectangle));
+ } catch (e) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
+ 'Operation was cancelled');
+ }
+ }
+
+ async FlashAreaAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let [x, y, width, height] = params;
+ [x, y, width, height] = this._scaleArea(x, y, width, height);
+ if (!this._checkArea(x, y, width, height)) {
+ invocation.return_error_literal(Gio.IOErrorEnum,
+ Gio.IOErrorEnum.CANCELLED,
+ "Invalid params");
+ return;
+ }
+ let flashspot = new Flashspot({ x, y, width, height });
+ flashspot.fire();
+ invocation.return_value(null);
+ }
+
+ async PickColorAsync(params, invocation) {
+ const screenshot = await this._createScreenshot(invocation, false, false);
+ if (!screenshot)
+ return;
+
+ const pickPixel = new PickPixel(screenshot);
+ try {
+ const color = await pickPixel.pickAsync();
+ const { red, green, blue } = color;
+ const retval = GLib.Variant.new('(a{sv})', [{
+ color: GLib.Variant.new('(ddd)', [
+ red / 255.0,
+ green / 255.0,
+ blue / 255.0,
+ ]),
+ }]);
+ invocation.return_value(retval);
+ } catch (e) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
+ 'Operation was cancelled');
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+};
+
+var SelectArea = GObject.registerClass(
+class SelectArea extends St.Widget {
+ _init() {
+ this._startX = -1;
+ this._startY = -1;
+ this._lastX = 0;
+ this._lastY = 0;
+ this._result = null;
+
+ super._init({
+ visible: false,
+ reactive: true,
+ x: 0,
+ y: 0,
+ });
+ Main.uiGroup.add_actor(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this);
+
+ const constraint = new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ });
+ this.add_constraint(constraint);
+
+ this._rubberband = new St.Widget({
+ style_class: 'select-area-rubberband',
+ visible: false,
+ });
+ this.add_actor(this._rubberband);
+ }
+
+ async selectAsync() {
+ global.display.set_cursor(Meta.Cursor.CROSSHAIR);
+ Main.uiGroup.set_child_above_sibling(this, null);
+ this.show();
+
+ try {
+ await this._grabHelper.grabAsync({ actor: this });
+ } finally {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.destroy();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ return this._result;
+ }
+
+ _getGeometry() {
+ return new Meta.Rectangle({
+ x: Math.min(this._startX, this._lastX),
+ y: Math.min(this._startY, this._lastY),
+ width: Math.abs(this._startX - this._lastX) + 1,
+ height: Math.abs(this._startY - this._lastY) + 1,
+ });
+ }
+
+ vfunc_motion_event(motionEvent) {
+ if (this._startX == -1 || this._startY == -1 || this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ [this._lastX, this._lastY] = [motionEvent.x, motionEvent.y];
+ this._lastX = Math.floor(this._lastX);
+ this._lastY = Math.floor(this._lastY);
+ let geometry = this._getGeometry();
+
+ this._rubberband.set_position(geometry.x, geometry.y);
+ this._rubberband.set_size(geometry.width, geometry.height);
+ this._rubberband.show();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ if (this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ [this._startX, this._startY] = [buttonEvent.x, buttonEvent.y];
+ this._startX = Math.floor(this._startX);
+ this._startY = Math.floor(this._startY);
+ this._rubberband.set_position(this._startX, this._startY);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_release_event() {
+ if (this._startX === -1 || this._startY === -1 || this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ this._result = this._getGeometry();
+ this.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._grabHelper.ungrab(),
+ });
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+var RecolorEffect = GObject.registerClass({
+ Properties: {
+ color: GObject.ParamSpec.boxed(
+ 'color', 'color', 'replacement color',
+ GObject.ParamFlags.WRITABLE,
+ Clutter.Color.$gtype),
+ chroma: GObject.ParamSpec.boxed(
+ 'chroma', 'chroma', 'color to replace',
+ GObject.ParamFlags.WRITABLE,
+ Clutter.Color.$gtype),
+ threshold: GObject.ParamSpec.float(
+ 'threshold', 'threshold', 'threshold',
+ GObject.ParamFlags.WRITABLE,
+ 0.0, 1.0, 0.0),
+ smoothing: GObject.ParamSpec.float(
+ 'smoothing', 'smoothing', 'smoothing',
+ GObject.ParamFlags.WRITABLE,
+ 0.0, 1.0, 0.0),
+ },
+}, class RecolorEffect extends Shell.GLSLEffect {
+ _init(params) {
+ this._color = new Clutter.Color();
+ this._chroma = new Clutter.Color();
+ this._threshold = 0;
+ this._smoothing = 0;
+
+ this._colorLocation = null;
+ this._chromaLocation = null;
+ this._thresholdLocation = null;
+ this._smoothingLocation = null;
+
+ super._init(params);
+
+ this._colorLocation = this.get_uniform_location('recolor_color');
+ this._chromaLocation = this.get_uniform_location('chroma_color');
+ this._thresholdLocation = this.get_uniform_location('threshold');
+ this._smoothingLocation = this.get_uniform_location('smoothing');
+
+ this._updateColorUniform(this._colorLocation, this._color);
+ this._updateColorUniform(this._chromaLocation, this._chroma);
+ this._updateFloatUniform(this._thresholdLocation, this._threshold);
+ this._updateFloatUniform(this._smoothingLocation, this._smoothing);
+ }
+
+ _updateColorUniform(location, color) {
+ if (!location)
+ return;
+
+ this.set_uniform_float(location,
+ 3, [color.red / 255, color.green / 255, color.blue / 255]);
+ this.queue_repaint();
+ }
+
+ _updateFloatUniform(location, value) {
+ if (!location)
+ return;
+
+ this.set_uniform_float(location, 1, [value]);
+ this.queue_repaint();
+ }
+
+ set color(c) {
+ if (this._color.equal(c))
+ return;
+
+ this._color = c;
+ this.notify('color');
+
+ this._updateColorUniform(this._colorLocation, this._color);
+ }
+
+ set chroma(c) {
+ if (this._chroma.equal(c))
+ return;
+
+ this._chroma = c;
+ this.notify('chroma');
+
+ this._updateColorUniform(this._chromaLocation, this._chroma);
+ }
+
+ set threshold(value) {
+ if (this._threshold === value)
+ return;
+
+ this._threshold = value;
+ this.notify('threshold');
+
+ this._updateFloatUniform(this._thresholdLocation, this._threshold);
+ }
+
+ set smoothing(value) {
+ if (this._smoothing === value)
+ return;
+
+ this._smoothing = value;
+ this.notify('smoothing');
+
+ this._updateFloatUniform(this._smoothingLocation, this._smoothing);
+ }
+
+ vfunc_build_pipeline() {
+ // Conversion parameters from https://en.wikipedia.org/wiki/YCbCr
+ const decl = `
+ vec3 rgb2yCrCb(vec3 c) { \n
+ float y = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; \n
+ float cr = 0.7133 * (c.r - y); \n
+ float cb = 0.5643 * (c.b - y); \n
+ return vec3(y, cr, cb); \n
+ } \n
+ \n
+ uniform vec3 chroma_color; \n
+ uniform vec3 recolor_color; \n
+ uniform float threshold; \n
+ uniform float smoothing; \n`;
+ const src = `
+ vec3 mask = rgb2yCrCb(chroma_color.rgb); \n
+ vec3 yCrCb = rgb2yCrCb(cogl_color_out.rgb); \n
+ float blend = \n
+ smoothstep(threshold, \n
+ threshold + smoothing, \n
+ distance(yCrCb.gb, mask.gb)); \n
+ cogl_color_out.rgb = \n
+ mix(recolor_color, cogl_color_out.rgb, blend); \n`;
+
+ this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, decl, src, false);
+ }
+});
+
+var PickPixel = GObject.registerClass(
+class PickPixel extends St.Widget {
+ _init(screenshot) {
+ super._init({ visible: false, reactive: true });
+
+ this._screenshot = screenshot;
+
+ this._result = null;
+ this._color = null;
+ this._inPick = false;
+
+ Main.uiGroup.add_actor(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this);
+
+ const constraint = new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ });
+ this.add_constraint(constraint);
+
+ const action = new Clutter.ClickAction();
+ action.connect('clicked', async () => {
+ await this._pickColor(...action.get_coords());
+ this._result = this._color;
+ this._grabHelper.ungrab();
+ });
+ this.add_action(action);
+
+ this._recolorEffect = new RecolorEffect({
+ chroma: new Clutter.Color({
+ red: 80,
+ green: 219,
+ blue: 181,
+ }),
+ threshold: 0.04,
+ smoothing: 0.07,
+ });
+ this._previewCursor = new St.Icon({
+ icon_name: 'color-pick',
+ icon_size: Meta.prefs_get_cursor_size(),
+ effect: this._recolorEffect,
+ visible: false,
+ });
+ Main.uiGroup.add_actor(this._previewCursor);
+ }
+
+ async pickAsync() {
+ global.display.set_cursor(Meta.Cursor.BLANK);
+ Main.uiGroup.set_child_above_sibling(this, null);
+ this.show();
+
+ this._pickColor(...global.get_pointer());
+
+ try {
+ await this._grabHelper.grabAsync({ actor: this });
+ } finally {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ this._previewCursor.destroy();
+
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.destroy();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ return this._result;
+ }
+
+ async _pickColor(x, y) {
+ if (this._inPick)
+ return;
+
+ this._inPick = true;
+ this._previewCursor.set_position(x, y);
+ [this._color] = await this._screenshot.pick_color(x, y);
+ this._inPick = false;
+
+ if (!this._color)
+ return;
+
+ this._recolorEffect.color = this._color;
+ this._previewCursor.show();
+ }
+
+ vfunc_motion_event(motionEvent) {
+ const { x, y } = motionEvent;
+ this._pickColor(x, y);
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+var FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds
+
+var Flashspot = GObject.registerClass(
+class Flashspot extends Lightbox.Lightbox {
+ _init(area) {
+ super._init(Main.uiGroup, {
+ inhibitEvents: true,
+ width: area.width,
+ height: area.height,
+ });
+ this.style_class = 'flashspot';
+ this.set_position(area.x, area.y);
+ }
+
+ fire(doneCallback) {
+ this.set({ visible: true, opacity: 255 });
+ this.ease({
+ opacity: 0,
+ duration: FLASHSPOT_ANIMATION_OUT_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ if (doneCallback)
+ doneCallback();
+ this.destroy();
+ },
+ });
+ }
+});