gnome-shell/js/ui/screenshot.js

2964 lines
100 KiB
JavaScript
Raw Normal View History

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
import Clutter from 'gi://Clutter';
import Cogl from 'gi://Cogl';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import GLib from 'gi://GLib';
import Graphene from 'gi://Graphene';
import Meta from 'gi://Meta';
import Mtk from 'gi://Mtk';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as GrabHelper from './grabHelper.js';
import * as Layout from './layout.js';
import * as Lightbox from './lightbox.js';
import * as Main from './main.js';
import * as MessageTray from './messageTray.js';
import * as Workspace from './workspace.js';
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');
import {loadInterfaceXML} from '../misc/fileUtils.js';
import {DBusSenderChecker} from '../misc/util.js';
const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot');
const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast');
const ScreencastProxy = Gio.DBusProxy.makeProxyWrapper(ScreencastIface);
const 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,
}));
}
});
export const 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(),
});
}
});
const 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);
}
});
const 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 [x, y] = event.get_coords();
const cursor = this._computeCursorType(x, 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.get_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.get_coords();
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, this._dragStartY] = event.get_coords();
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.
const [x, y] = event.get_coords();
this._updateCursor(x, y);
return Clutter.EVENT_STOP;
}
_onMotion(event, sequence) {
if (!this._dragButton) {
const [x, y] = event.get_coords();
this._updateCursor(x, 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.get_coords();
this._lastX = Math.floor(this._lastX);
this._lastY = Math.floor(this._lastY);
} else {
const [x, y] = event.get_coords();
let dx = Math.round(x - this._dragStartX);
let dy = Math.round(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) {
const button = event.get_button();
if (button === Clutter.BUTTON_PRIMARY ||
button === Clutter.BUTTON_SECONDARY)
return this._onPress(event, button, null);
return Clutter.EVENT_PROPAGATE;
}
vfunc_button_release_event(event) {
const button = event.get_button();
if (button === Clutter.BUTTON_PRIMARY ||
button === Clutter.BUTTON_SECONDARY)
return this._onRelease(event, button, null);
return Clutter.EVENT_PROPAGATE;
}
vfunc_motion_event(event) {
return this._onMotion(event, null);
}
vfunc_touch_event(event) {
const eventType = event.type();
if (eventType === Clutter.EventType.TOUCH_BEGIN)
return this._onPress(event, 'touch', event.get_event_sequence());
else if (eventType === Clutter.EventType.TOUCH_END)
return this._onRelease(event, 'touch', event.get_event_sequence());
else if (eventType === Clutter.EventType.TOUCH_UPDATE)
return this._onMotion(event, event.get_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);
}
});
const 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;
}
});
const 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;
}
});
const 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,
};
export const 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._screencastProxy.connectSignal('Error',
() => this._screencastFailed());
this._screencastProxy.connect('notify::g-name-owner', () => {
if (this._screencastProxy.g_name_owner)
return;
if (!this._screencastInProgress)
return;
this._screencastFailed();
});
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.add_child(new Tooltip(this._shotButton, {
text: _('Take Screenshot'),
style_class: 'screenshot-ui-tooltip',
visible: false,
}));
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.add_child(new Tooltip(this._castButton, {
text: _('Record Screen'),
style_class: 'screenshot-ui-tooltip',
visible: false,
}));
this._shotButton.bind_property('checked', this._castButton, 'checked',
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.INVERT_BOOLEAN);
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.add_child(new Tooltip(this._captureButton, {
/* Translators: since this string refers to an action,
it needs to be phrased as a verb. */
text: _('Capture'),
style_class: 'screenshot-ui-tooltip',
visible: false,
}));
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;
}
// Translators: notification title.
this._showNotification(_('Screencast recorded'));
}
_screencastFailed() {
this._setScreencastInProgress(false);
// Translators: notification title.
this._showNotification(_('Screencast ended unexpectedly'));
}
_showNotification(title) {
// 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,
title,
// 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() {
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.get_key_symbol();
if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_space ||
((event.get_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 (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}`;
}
/**
* Adds a record of a screenshot file in the recently used files list.
*
* @param {Gio.File} screenshotFile - The screenshot file.
*/
function saveRecentFile(screenshotFile) {
const recentFile =
GLib.build_filenamev([GLib.get_user_data_dir(), 'recently-used.xbel']);
const uri = screenshotFile.get_uri();
const bookmarks = new GLib.BookmarkFile();
try {
bookmarks.load_from_file(recentFile);
} catch (e) {
if (!e.matches(GLib.BookmarkFileError, GLib.BookmarkFileError.FILE_NOT_FOUND)) {
log(`Could not open recent file ${uri}: ${e.message}`);
return;
}
}
try {
bookmarks.add_application(uri, GLib.get_prgname(), 'gio open %u');
bookmarks.to_file(recentFile);
} catch (e) {
log(`Could not save recent file ${uri}: ${e.message}`);
}
}
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.
saveRecentFile(file);
}
// 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.
*/
export 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.
*/
export function showScreenshotUI() {
Main.screenshotUI.open().catch(err => {
logError(err, 'Error opening the screenshot UI');
});
}
/**
* Shows the screen recording UI.
*/
export function showScreenRecordingUI() {
Main.screenshotUI.open(UIMode.SCREENCAST).catch(err => {
logError(err, 'Error opening the screenshot UI');
});
}
export class ScreenshotService {
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());
}
}
}
export const 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 Mtk.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(event) {
if (this._startX === -1 || this._startY === -1 || this._result)
return Clutter.EVENT_PROPAGATE;
[this._lastX, this._lastY] = event.get_coords();
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(event) {
if (this._result)
return Clutter.EVENT_PROPAGATE;
[this._startX, this._startY] = event.get_coords();
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;
}
});
const 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);
}
});
export const 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(event) {
const [x, y] = event.get_coords();
this._pickColor(x, y);
return Clutter.EVENT_PROPAGATE;
}
});
const FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds
export const 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();
},
});
}
});