/* extension.js
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

/* exported init */

const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi;

const ExtensionUtils = imports.misc.extensionUtils;

const CtrlAltTab = imports.ui.ctrlAltTab;
const Layout = imports.ui.layout;
const Main = imports.ui.main;
const Overview = imports.ui.overview;

const _ = ExtensionUtils.gettext;
const Me = ExtensionUtils.getCurrentExtension();

const TRAY_ICON_SIZE = 24;

const STANDARD_TRAY_ICON_IMPLEMENTATIONS = {
    'bluetooth-applet': 'bluetooth',
    'gnome-volume-control-applet': 'volume', // renamed to gnome-sound-applet
                                             // when moved to control center
    'gnome-sound-applet': 'volume',
    'nm-applet': 'network',
    'gnome-power-manager': 'battery',
    'keyboard': 'keyboard',
    'a11y-keyboard': 'a11y',
    'kbd-scrolllock': 'keyboard',
    'kbd-numlock': 'keyboard',
    'kbd-capslock': 'keyboard',
    'ibus-ui-gtk': 'keyboard',
};

// Offset of the original position from the bottom-right corner
const CONCEALED_WIDTH = 3;
const REVEAL_ANIMATION_TIME = 0.2;
const TEMP_REVEAL_TIME = 2;

const BARRIER_THRESHOLD = 70;
const BARRIER_TIMEOUT = 1000;

const SlideDirection = {
    LEFT: 0,
    RIGHT: 1,
};

function getRtlSlideDirection(direction, actor) {
    let rtl = actor.text_direction === Clutter.TextDirection.RTL;
    if (rtl) direction = direction === SlideDirection.LEFT ? SlideDirection.RIGHT : SlideDirection.LEFT;
    return direction;
}

const SlideLayout = GObject.registerClass(
class SlideLayout extends Clutter.FixedLayout {
    _init() {
        this._slideX = 1;
        this._direction = SlideDirection.LEFT;
        super._init();
    }

    vfunc_get_preferred_width(container, forHeight) {
        let child = container.get_first_child();
        let [minWidth, natWidth] = child.get_preferred_width(forHeight);
        minWidth *= this._slideX;
        natWidth *= this._slideX;
        return [minWidth, natWidth];
    }

    vfunc_allocate(container, box) {
        let child = container.get_first_child();
        let availWidth = Math.round(box.x2 - box.x1);
        let availHeight = Math.round(box.y2 - box.y1);
        let [, natWidth] = child.get_preferred_width(availHeight);
        // Align the actor inside the clipped box, as the actor's alignment
        // flags only determine what to do if the allocated box is bigger
        // than the actor's box.
        let realDirection = getRtlSlideDirection(this._direction, child);
        let alignX = realDirection === SlideDirection.LEFT ? availWidth - natWidth : availWidth - natWidth * this._slideX;
        let actorBox = new Clutter.ActorBox();
        actorBox.x1 = box.x1 + alignX;
        actorBox.x2 = actorBox.x1 + (child.x_expand ? availWidth : natWidth);
        actorBox.y1 = box.y1;
        actorBox.y2 = actorBox.y1 + availHeight;
        child.allocate(actorBox);
    }

    // eslint-disable-next-line camelcase
    set slide_x(value) {
        if (this._slideX === value) return;
        this._slideX = value;
        this.layout_changed();
    }
});

const LegacyTray = GObject.registerClass(
class LegacyTray extends St.Widget {
    _init() {
        super._init({
            name: 'legacyTray',
            clip_to_allocation: true,
            layout_manager: new Clutter.BinLayout(),
        });
        this.add_constraint(new Layout.MonitorConstraint({
            primary: true,
            work_area: true,
        }));
        this._slideLayout = new SlideLayout();
        this._slider = new St.Widget({
            x_expand: true,
            y_expand: true,
            x_align: Clutter.ActorAlign.START,
            y_align: Clutter.ActorAlign.END,
            layout_manager: this._slideLayout,
        });
        this.add_child(this._slider);
        this._slider.connect('notify::allocation', this._syncBarrier.bind(this));
        this._box = new St.BoxLayout({ style_class: 'legacy-tray' });
        this._slider.add_child(this._box);
        this._concealHandle = new St.Button({
            style_class: 'legacy-tray-handle',
            /* translators: 'Hide' is a verb */
            accessible_name: _('Hide tray'),
            can_focus: true,
        });
        this._concealHandle.child = new St.Icon({ icon_name: 'go-previous-symbolic' });
        this._concealHandleTimeoutId = 0;
        this._box.add_child(this._concealHandle);
        this._iconBox = new St.BoxLayout({ style_class: 'legacy-tray-icon-box' });
        this._box.add_child(this._iconBox);
        this._revealHandle = new St.Button({ style_class: 'legacy-tray-handle' });
        this._revealHandle.child = new St.Icon({ icon_name: 'go-next-symbolic' });
        this._box.add_child(this._revealHandle);
        this._revealHandle.bind_property('visible', this._concealHandle, 'visible', GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.INVERT_BOOLEAN);
        this._revealHandle.connect('notify::visible', this._sync.bind(this));
        this._revealHandle.connect('notify::hover', this._sync.bind(this));
        this._revealHandle.connect('clicked', () => {
            this._concealHandle.show();
        });
        this._concealHandle.connect('clicked', () => {
            this._revealHandle.show();
        });
        this._horizontalBarrier = null;
        this._pressureBarrier = new Layout.PressureBarrier(BARRIER_THRESHOLD, BARRIER_TIMEOUT, Shell.ActionMode.NORMAL);
        this._pressureBarrier.connect('trigger', () => {
            this._concealHandle.show();
        });
        Main.layoutManager.addChrome(this, { affectsInputRegion: false });
        Main.layoutManager.trackChrome(this._slider, { affectsInputRegion: true });
        Main.uiGroup.add_child(this);
        Main.ctrlAltTabManager.addGroup(this, _('Status Icons'), 'focus-legacy-systray-symbolic', {
            sortGroup: CtrlAltTab.SortGroup.BOTTOM,
        });
        this._trayManager = new Shell.TrayManager();
        this._trayManager.connect('tray-icon-added', this._onTrayIconAdded.bind(this));
        this._trayManager.connect('tray-icon-removed', this._onTrayIconRemoved.bind(this));
        this._trayManager.manage_screen(this);
        Main.overview.connectObject(
            'showing', () => {
                this._slider.remove_all_transitions();
                this._slider.ease({
                    opacity: 0,
                    duration: Overview.ANIMATION_TIME,
                    mode: Clutter.AnimationMode.EASE_OUT_QUAD,
                });
            },
            'shown', () => this._sync(),
            'hiding', () => {
                this._sync();
                this._slider.remove_all_transitions();
                this._slider.ease({
                    opacity: 255,
                    duration: Overview.ANIMATION_TIME,
                    mode: Clutter.AnimationMode.EASE_OUT_QUAD,
                });
            }, this);
        Main.layoutManager.connectObject('monitors-changed', () => this._sync(), this);
        global.display.connectObject('in-fullscreen-changed', () => this._sync(), this);
        Main.sessionMode.connectObject('updated', () => this._sync(), this);
        this._sync();
    }

    _onTrayIconAdded(tm, icon) {
        let wmClass = icon.wm_class ? icon.wm_class.toLowerCase() : '';
        if (STANDARD_TRAY_ICON_IMPLEMENTATIONS[wmClass] !== undefined) return;
        icon.set({
            height: TRAY_ICON_SIZE,
            width: TRAY_ICON_SIZE,
            x_align: Clutter.ActorAlign.CENTER,
            y_align: Clutter.ActorAlign.CENTER,
        });
        let button = new St.Button({
            child: icon,
            style_class: 'legacy-tray-icon',
            button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO | St.ButtonMask.THREE,
            can_focus: true,
            x_expand: true,
            y_expand: true,
        });
        let app = Shell.WindowTracker.get_default().get_app_from_pid(icon.pid);
        if (!app) app = Shell.AppSystem.get_default().lookup_startup_wmclass(wmClass);
        if (!app) app = Shell.AppSystem.get_default().lookup_desktop_wmclass(wmClass);
        button.accessible_name = app ? app.get_name() : icon.title;
        button.connect('clicked', () => {
            icon.click(Clutter.get_current_event());
        });
        button.connect('key-press-event', () => {
            icon.click(Clutter.get_current_event());
            return Clutter.EVENT_PROPAGATE;
        });
        button.connect('key-focus-in', () => {
            this._concealHandle.show();
        });
        this._iconBox.add_child(button);
        if (!this._concealHandle.visible) {
            this._concealHandle.show();
            if (this._concealHandleTimeoutId !== 0) GLib.source_remove(this._concealHandleTimeoutId);
            this._concealHandleTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TEMP_REVEAL_TIME, () => {
                this._concealHandleTimeoutId = 0;
                this._concealHandle.hide();
                return GLib.SOURCE_REMOVE;
            });
        }
    }

    _onTrayIconRemoved(tm, icon) {
        if (!this.contains(icon)) return;
        icon.get_parent().destroy();
        this._sync();
    }

    _sync() {
        // FIXME: we no longer treat tray icons as notifications
        let allowed = Main.sessionMode.hasNotifications;
        let hasIcons = this._iconBox.get_n_children() > 0;
        let inOverview = Main.overview.visible && !Main.overview.animationInProgress;
        let inFullscreen = Main.layoutManager.primaryMonitor.inFullscreen;
        this.visible = allowed && hasIcons && !inOverview && !inFullscreen;
        if (!hasIcons) this._concealHandle.hide();
        let targetSlide;
        if (this._concealHandle.visible) {
            targetSlide = 1.0;
        } else if (!hasIcons) {
            targetSlide = 0.0;
        } else {
            let [, boxWidth] = this._box.get_preferred_width(-1);
            let [, handleWidth] = this._revealHandle.get_preferred_width(-1);
            if (this._revealHandle.hover) {
                targetSlide = handleWidth / boxWidth;
            } else {
                targetSlide = CONCEALED_WIDTH / boxWidth;
            }
        }
        this._slideLayout.slide_x = targetSlide;
        if (!this.visible) this._unsetBarrier();
    }

    _syncBarrier() {
        let [x, y] = this._slider.get_transformed_position();
        let [w, h] = this._slider.get_transformed_size();
        if (isNaN(x) || isNaN(y) || isNaN(w) || isNaN(h)) return;
        let x1 = Math.round(x);
        let rtl = this._slider.get_text_direction() === Clutter.TextDirection.RTL;
        if (rtl) x1 += Math.round(w);
        let x2 = x1;
        let y1 = Math.round(y);
        let y2 = y1 + Math.round(h);
        if (this._horizontalBarrier && this._horizontalBarrier.x1 === x1 && this._horizontalBarrier.y1 === y1 && this._horizontalBarrier.x2 === x2 && this._horizontalBarrier.y2 === y2) return;
        this._unsetBarrier();
        let directions = rtl ? Meta.BarrierDirection.NEGATIVE_X : Meta.BarrierDirection.POSITIVE_X;
        this._horizontalBarrier = new Meta.Barrier({
            backend: global.backend,
            x1: x1, x2: x2,
            y1: y1, y2: y2,
            directions: directions,
        });
        this._pressureBarrier.addBarrier(this._horizontalBarrier);
    }

    _unsetBarrier() {
        if (this._horizontalBarrier === null) return;
        this._pressureBarrier.removeBarrier(this._horizontalBarrier);
        this._horizontalBarrier.destroy();
        this._horizontalBarrier = null;
    }

    destroy() {
        if (this._concealHandleTimeoutId !== 0) GLib.source_remove(this._concealHandleTimeoutId);
        this._concealHandleTimeoutId = 0;
        Main.sessionMode.disconnectObject(this);
        global.display.disconnectObject(this);
        Main.layoutManager.disconnectObject(this);
        Main.overview.disconnectObject(this);
        this._trayManager.unmanage_screen();
        this._trayManager = null;
        Main.ctrlAltTabManager.removeGroup(this);
        Main.uiGroup.remove_child(this);
        Main.layoutManager.untrackChrome(this._slider);
        Main.layoutManager.removeChrome(this);
        super.destroy();
    }
});

class Extension {
    constructor(uuid) {
        ExtensionUtils.initTranslations();
        this._uuid = uuid;
    }

    _onEnable() {
        this._legacyTray = new LegacyTray();
    }

    enable() {
        if (Main.layoutManager._startingUp) {
            Main.layoutManager.connectObject('startup-complete', () => this._onEnable(), this);
        } else {
            this._onEnable();
        }
    }

    disable() {
        Main.layoutManager.disconnectObject(this);
        this._legacyTray.destroy();
        this._legacyTray = null;
    }
}

function init(meta) {
    return new Extension(meta.uuid);
}
