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 {ScreencastErrors, ScreencastError} from '../misc/dbusErrors.js';
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({
            orientation: Clutter.Orientation.VERTICAL,
            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();
        });

        if (!this._widget.label_actor)
            this._widget.label_actor = this;
    }

    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.W_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.E_RESIZE;
        } else if (leftX - x < 0 && x - rightX < 0) {
            if (topY - y >= 0 && topY - y <= threshold)
                return Meta.Cursor.N_RESIZE;
            else if (y - bottomY >= 0 && y - bottomY <= threshold)
                return Meta.Cursor.S_RESIZE;
            else if (topY - y < 0 && y - bottomY < 0)
                return Meta.Cursor.MOVE;
        }

        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) {
                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.W_RESIZE ||
                cursor === Meta.Cursor.SW_RESIZE) {
                this._startX = rightX;
                this._lastX = leftX;
            }
            if (cursor === Meta.Cursor.NE_RESIZE ||
                cursor === Meta.Cursor.E_RESIZE ||
                cursor === Meta.Cursor.SE_RESIZE) {
                this._startX = leftX;
                this._lastX = rightX;
            }
            if (cursor === Meta.Cursor.NW_RESIZE ||
                cursor === Meta.Cursor.N_RESIZE ||
                cursor === Meta.Cursor.NE_RESIZE) {
                this._startY = bottomY;
                this._lastY = topY;
            }
            if (cursor === Meta.Cursor.SW_RESIZE ||
                cursor === Meta.Cursor.S_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) {
                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.W_RESIZE ||
                    this._dragCursor === Meta.Cursor.E_RESIZE)
                    dy = 0;
                if (this._dragCursor === Meta.Cursor.N_RESIZE ||
                    this._dragCursor === Meta.Cursor.S_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.W_RESIZE)
                        this._dragCursor = Meta.Cursor.E_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.E_RESIZE)
                        this._dragCursor = Meta.Cursor.W_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.N_RESIZE)
                        this._dragCursor = Meta.Cursor.S_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.S_RESIZE)
                        this._dragCursor = Meta.Cursor.N_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 UIWindowSelectorWindowContent = GObject.registerClass(
class UIWindowSelectorWindowContent extends Clutter.Actor {
    constructor(actor) {
        super({
            x_expand: true,
            y_expand: true,
        });

        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',
            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._border.connect('style-changed', () => {
            this._borderSize =
                this._border.get_theme_node().get_border_width(St.Side.TOP);
        });
        this.add_child(this._border);

        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 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 UIWindowSelectorWindow = GObject.registerClass(
class UIWindowSelectorWindow extends St.Button {
    _init(actor, params) {
        super._init({
            child: new UIWindowSelectorWindowContent(actor),
            ...params,
        });
    }

    get boundingBox() {
        return this.child.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 this.child.cursorPoint;
    }

    get bufferScale() {
        return this.child.bufferScale;
    }

    get windowContent() {
        return this.child.windowContent;
    }

    addCursorTexture(content, point, scale) {
        this.child.addCursorTexture(content, point, scale);
    }

    getCursorTexture() {
        return this.child.getCursorTexture();
    }

    setCursorVisible(visible) {
        this.child.setCursorVisible(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,
    SCREENSHOT_ONLY: 2,
};

const ScreencastPhase = {
    STARTUP: 'STARTUP',
    RECORDING: 'RECORDING',
};

export const ScreenshotUI = GObject.registerClass({
    Properties: {
        'screencast-in-progress': GObject.ParamSpec.boolean(
            'screencast-in-progress', null, null,
            GObject.ParamFlags.READABLE,
            false),
    },
    Signals: {
        'screenshot-taken': {param_types: [Gio.File.$gtype]},
        'closed': {},
    },
}, 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._currentMode = UIMode.SCREENSHOT;

        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._syncCastButton();
            });

        this._screencastProxy.connectSignal('Error', (proxy, sender, [errorName, message]) =>
            this._screencastFailed(ScreencastPhase.RECORDING,
                Gio.DBusError.new_for_dbus_error(errorName, message)));

        this._screencastProxy.connect('notify::g-name-owner', () => {
            if (this._screencastProxy.g_name_owner)
                return;

            if (!this._screencastInProgress)
                return;

            // If the recorder crashed while we're starting it in _startScreencast(),
            // let the catch-block there handle the error.
            if (this._screencastStarting)
                return;

            this._screencastFailed(ScreencastPhase.RECORDING,
                new GLib.Error(ScreencastErrors, ScreencastError.SERVICE_CRASH,
                    'Service crashed'));
        });

        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,
            orientation: Clutter.Orientation.VERTICAL,
            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',
            x_expand: true,
            y_expand: true,
        }));
        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().catch(logError));
        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, _event, _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);
    }

    _syncCastButton() {
        const visible = this._screencastSupported;
        const reactive = visible &&
            this._currentMode !== UIMode.SCREENSHOT_ONLY &&
            Main.sessionMode.allowScreencast;

        this._castButton.set({visible, reactive});
    }

    _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._currentMode = mode;
        this._castButton.checked = mode === UIMode.SCREENCAST;
        this._syncCastButton();

        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();

        this.emit('closed');
    }

    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];
    }

    async _onCaptureButtonClicked() {
        if (this._shotButton.checked) {
            try {
                await this._saveScreenshot();
            } catch (e) {
                logError(e);
            }
            this.close();
        } else {
            // Screencast closes the UI on its own.
            this._startScreencast();
        }
    }

    async _saveScreenshot() {
        let file = null;

        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;

            try {
                file = await 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;

            try {
                file = await 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');
            }
        }

        if (file)
            this.emit('screenshot-taken', file);
    }

    async _startScreencast(nRetries = 0) {
        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);
        let retry = false;

        try {
            this._screencastStarting = true;

            const [, 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" */
                    /* xgettext:no-c-format */
                    _('Screencast From %d %t'),
                ]),
                {'draw-cursor': new GLib.Variant('b', drawCursor)});

            this._screencastPath = path;
        } catch (error) {
            // Recorder service disconnected without reply -> service crash
            // That should have blocklisted the pipeline that caused the crash,
            // so try again.
            if (error.matches(Gio.DBusError, Gio.DBusError.NO_REPLY) && nRetries < 2)
                retry = true;
            else
                this._screencastFailed(ScreencastPhase.STARTUP, error);
        }

        delete this._screencastStarting;

        if (retry) {
            console.log('Screencast service crashed during startup, trying again');
            this._setScreencastInProgress(false);
            this._startScreencast(nRetries + 1);
        }
    }

    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(phase, error) {
        console.error(`Screencast failed during phase ${phase}: ${error}`);

        this._setScreencastInProgress(false);

        switch (phase) {
        case ScreencastPhase.STARTUP:
            delete this._screencastPath;

            // Translators: notification title.
            this._showNotification(_('Screencast failed to start'));
            break;

        case ScreencastPhase.RECORDING:
            if (error.matches(ScreencastErrors, ScreencastError.OUT_OF_DISK_SPACE)) {
                // Translators: notification title.
                this._showNotification(_('Screencast ended: Out of disk space'));
            } else if (error.matches(ScreencastErrors, ScreencastError.SERVICE_CRASH)) {
                // We can encourage user to try again on service crashes since the
                // recorder will auto-blocklist the pipeline that crashed.

                // Translators: notification title.
                this._showNotification(_('Screencast ended unexpectedly, please try again'));
            } else {
                // Translators: notification title.
                this._showNotification(_('Screencast ended unexpectedly'));
            }

            break;
        }
    }

    _showNotification(title) {
        const source = new MessageTray.Source({
            // Translators: notification source name.
            title: _('Screenshot'),
            iconName: 'screencast-recorded-symbolic',
        });
        const notification = new MessageTray.Notification({
            source,
            title,
            // Translators: notification body when a screencast was recorded.
            body: this._screencastPath ? _('Click here to view the video.') : '',
            isTransient: true,
        });

        if (this._screencastPath) {
            const file = Gio.file_new_for_path(this._screencastPath);

            // 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');
                }
            });

            Main.overview.hide();
            Main.panel.closeCalendar();
        }

        Main.messageTray.add(source);
        source.addNotification(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 ||
            symbol === Clutter.KEY_KP_Enter || symbol === Clutter.KEY_ISO_Enter ||
            ((event.get_state() & Clutter.ModifierType.CONTROL_MASK) &&
             (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C))) {
            this._onCaptureButtonClicked().catch(logError);
            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}`;
    }

    /**
     * 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 coglContext = global.stage.context.get_backend().get_cogl_context();
    const pixels = pixbuf.read_pixel_bytes();
    const content =
        St.ImageContent.new_with_preferred_size(pixbuf.width, pixbuf.height);
    content.set_bytes(
        coglContext,
        pixels,
        Cogl.PixelFormat.RGBA_8888,
        pixbuf.width,
        pixbuf.height,
        pixbuf.rowstride
    );

    // Show a notification.
    const source = new MessageTray.Source({
        // Translators: notification source name.
        title: _('Screenshot'),
        iconName: 'screenshot-recorded-symbolic',
    });
    const notification = new MessageTray.Notification({
        source,
        // Translators: notification title.
        title: _('Screenshot captured'),
        // Translators: notification body when a screenshot was captured.
        body: _('You can paste the image from the clipboard.'),
        datetime: time,
        gicon: content,
        isTransient: true,
    });

    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');
            }

            Main.overview.hide();
            Main.panel.closeCalendar();
        });
    }

    Main.messageTray.add(source);
    source.addNotification(notification);

    return file;
}

/**
 * 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);
    return _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 {
            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 {
            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 {
            invocation.return_value(new GLib.Variant('(bs)', [false, '']));
        } finally {
            this._removeShooterForSender(invocation.get_sender());
        }
    }

    async InteractiveScreenshotAsync(params, invocation) {
        try {
            await this._senderChecker.checkInvocation(invocation);
        } catch (e) {
            invocation.return_gerror(e);
            return;
        }

        Main.screenshotUI.connectObject(
            'screenshot-taken', (ui, file) => {
                Main.screenshotUI.disconnectObject(invocation);
                invocation.return_value(new GLib.Variant('(bs)', [true, file.get_uri()]));
            },
            'closed', () => {
                Main.screenshotUI.disconnectObject(invocation);
                invocation.return_value(new GLib.Variant('(bs)', [false, '']));
            },
            invocation);


        try {
            Main.screenshotUI.open(UIMode.SCREENSHOT_ONLY);
        } catch {
            Main.screenshotUI.disconnectObject(invocation);
            invocation.return_value(new GLib.Variant('(bs)', [false, '']));
        }
    }

    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 {
            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 {
            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_child(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_child(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', null, null,
            GObject.ParamFlags.WRITABLE,
            Cogl.Color.$gtype),
        chroma: GObject.ParamSpec.boxed(
            'chroma', null, null,
            GObject.ParamFlags.WRITABLE,
            Cogl.Color.$gtype),
        threshold: GObject.ParamSpec.float(
            'threshold', null, null,
            GObject.ParamFlags.WRITABLE,
            0.0, 1.0, 0.0),
        smoothing: GObject.ParamSpec.float(
            'smoothing', null, null,
            GObject.ParamFlags.WRITABLE,
            0.0, 1.0, 0.0),
    },
}, class RecolorEffect extends Shell.GLSLEffect {
    _init(params) {
        this._color = new Cogl.Color();
        this._chroma = new Cogl.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(Cogl.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_child(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 Cogl.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_child(this._previewCursor);
    }

    async pickAsync() {
        global.display.set_cursor(Meta.Cursor.NONE);
        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();
            },
        });
    }
});
