/* * This file is part of the VesperOS system-taskbar extension. * * 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. */ import Clutter from 'gi://Clutter' import Gio from 'gi://Gio' import GLib from 'gi://GLib' import GObject from 'gi://GObject' import Pango from 'gi://Pango' import Shell from 'gi://Shell' import St from 'gi://St' import * as AppFavorites from 'resource:///org/gnome/shell/ui/appFavorites.js' import * as Main from 'resource:///org/gnome/shell/ui/main.js' import * as Util from 'resource:///org/gnome/shell/misc/util.js' import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js' import * as Utils from './utils.js' import { SETTINGS } from './extension.js' const MENU_WIDTH = 460 const MENU_HEIGHT = 560 const APP_ICON_SIZE = 48 const GRID_COLUMNS = 4 const CELL_SIZE = Math.floor(MENU_WIDTH / GRID_COLUMNS) // 115 export const Win10StartMenu = GObject.registerClass( class Win10StartMenu extends St.Widget { _init(dtpPanel) { super._init({ style_class: 'win10-start-menu', reactive: true, visible: false, width: MENU_WIDTH, height: MENU_HEIGHT, }) this._dtpPanel = dtpPanel this._appSystem = Shell.AppSystem.get_default() this.isOpen = false this._captureId = 0 this._focusId = 0 this._sourceActor = null this._customStylesheet = null this._powerPopup = null this._timeoutsHandler = new Utils.TimeoutsHandler() this._buildUI() Main.uiGroup.add_child(this) this._signalsHandler = new Utils.GlobalSignalsHandler() this._signalsHandler.add( [Main.overview, 'showing', () => { if (this.isOpen) this.close() }], [this._appSystem, 'installed-changed', () => { this._allAppsList.remove_all_children() }], [SETTINGS, 'changed::start-menu-stylesheet', () => this._reloadStylesheet()], ) this._reloadStylesheet() } _reloadStylesheet() { let theme = St.ThemeContext.get_for_stage(global.stage).get_theme() if (this._customStylesheet) { theme.unload_stylesheet(this._customStylesheet) this._customStylesheet = null } let path = SETTINGS.get_string('start-menu-stylesheet').trim() if (path) { let file = Gio.File.new_for_path(path) if (file.query_exists(null)) { this._customStylesheet = file theme.load_stylesheet(this._customStylesheet) } } } _buildUI() { let outer = new St.BoxLayout({ style_class: 'win10-start-menu-inner', vertical: true, reactive: true, x_expand: true, y_expand: true, }) // ── Search bar ────────────────────────────────────────────────────────── this._searchEntry = new St.Entry({ style_class: 'win10-search-entry', hint_text: _('Search apps'), can_focus: true, x_expand: true, }) this._searchEntry.clutter_text.connect( 'text-changed', () => this._onSearchTextChanged(), ) outer.add_child(this._searchEntry) // ── Content stack (pinned / all-apps / search results) ────────────────── this._stack = new St.Widget({ layout_manager: new Clutter.BinLayout(), y_expand: true, x_expand: true, }) this._pinnedView = this._buildPinnedView() this._allAppsView = this._buildAllAppsView() this._searchView = this._buildSearchView() this._allAppsView.visible = false this._searchView.visible = false this._stack.add_child(this._pinnedView) this._stack.add_child(this._allAppsView) this._stack.add_child(this._searchView) outer.add_child(this._stack) // ── Footer ────────────────────────────────────────────────────────────── outer.add_child(this._buildFooter()) this._outer = outer this.add_child(outer) } _buildPinnedView() { let view = new St.BoxLayout({ vertical: true, x_expand: true, y_expand: true, }) let header = new St.BoxLayout({ style_class: 'win10-section-header' }) header.add_child( new St.Label({ text: _('Pinned'), style_class: 'win10-section-label', x_expand: true, y_align: Clutter.ActorAlign.CENTER, }), ) let allAppsBtn = new St.Button({ label: _('All apps ›'), style_class: 'win10-link-button', y_align: Clutter.ActorAlign.CENTER, can_focus: true, }) allAppsBtn.connect('clicked', () => this._showView('all')) header.add_child(allAppsBtn) view.add_child(header) let scroll = new St.ScrollView({ style_class: 'win10-apps-scroll', hscrollbar_policy: St.PolicyType.NEVER, vscrollbar_policy: St.PolicyType.AUTOMATIC, y_expand: true, x_expand: true, }) this._pinnedGrid = new St.BoxLayout({ style_class: 'win10-app-grid', vertical: true, x_expand: true, }) scroll.add_child(this._pinnedGrid) view.add_child(scroll) return view } _buildAllAppsView() { let view = new St.BoxLayout({ vertical: true, x_expand: true, y_expand: true, }) let header = new St.BoxLayout({ style_class: 'win10-section-header' }) let backBtn = new St.Button({ style_class: 'win10-link-button', y_align: Clutter.ActorAlign.CENTER, can_focus: true, }) let backBox = new St.BoxLayout({ y_align: Clutter.ActorAlign.CENTER }) backBox.add_child( new St.Icon({ icon_name: 'go-previous-symbolic', icon_size: 14, style_class: 'win10-back-icon', }), ) backBox.add_child( new St.Label({ text: _('Back'), y_align: Clutter.ActorAlign.CENTER }), ) backBtn.set_child(backBox) backBtn.connect('clicked', () => this._showView('pinned')) header.add_child(backBtn) header.add_child( new St.Label({ text: _('All apps'), style_class: 'win10-section-label', x_expand: true, y_align: Clutter.ActorAlign.CENTER, }), ) view.add_child(header) let scroll = new St.ScrollView({ style_class: 'win10-apps-scroll', hscrollbar_policy: St.PolicyType.NEVER, vscrollbar_policy: St.PolicyType.AUTOMATIC, y_expand: true, x_expand: true, }) this._allAppsList = new St.BoxLayout({ vertical: true, x_expand: true }) scroll.add_child(this._allAppsList) view.add_child(scroll) return view } _buildSearchView() { let view = new St.BoxLayout({ vertical: true, x_expand: true, y_expand: true, }) view.add_child( new St.Label({ text: _('Search results'), style_class: 'win10-section-label', style: 'margin: 8px 12px 4px;', }), ) let scroll = new St.ScrollView({ style_class: 'win10-apps-scroll', hscrollbar_policy: St.PolicyType.NEVER, vscrollbar_policy: St.PolicyType.AUTOMATIC, y_expand: true, x_expand: true, }) this._searchList = new St.BoxLayout({ vertical: true, x_expand: true }) scroll.add_child(this._searchList) view.add_child(scroll) return view } _buildFooter() { let footer = new St.BoxLayout({ style_class: 'win10-footer', x_expand: true, }) // User button (left) let userBtn = new St.Button({ style_class: 'win10-footer-button', x_expand: true, x_align: Clutter.ActorAlign.START, can_focus: true, }) let userBox = new St.BoxLayout() userBox.add_child( new St.Icon({ icon_name: 'avatar-default-symbolic', icon_size: 20, style_class: 'win10-footer-icon', }), ) let realName = GLib.get_real_name() || '' let displayName = realName && realName !== 'Unknown' ? realName : GLib.get_user_name() userBox.add_child( new St.Label({ text: displayName, style_class: 'win10-footer-label', y_align: Clutter.ActorAlign.CENTER, }), ) userBtn.set_child(userBox) footer.add_child(userBtn) // Power button (right) let powerBtn = new St.Button({ style_class: 'win10-footer-button win10-power-button', can_focus: true, }) powerBtn.set_child( new St.Icon({ icon_name: 'system-shutdown-symbolic', icon_size: 20 }), ) powerBtn.connect('clicked', () => this._openPowerMenu(powerBtn)) this._powerBtn = powerBtn footer.add_child(powerBtn) return footer } _openPowerMenu(powerBtn) { // Toggle: clicking again while open closes it if (this._powerPopup) { this._powerPopup.destroy() return } let popup = new St.BoxLayout({ style_class: 'win10-power-popup', vertical: true, reactive: true, }) for (let { label, icon, fn } of [ { label: _('Lock'), icon: 'changes-prevent-symbolic', fn: () => { this.close() Util.spawn(['loginctl', 'lock-session']) }, }, { label: _('Sign out'), icon: 'system-log-out-symbolic', fn: () => Util.spawn(['gnome-session-quit', '--logout']), }, { label: _('Sleep'), icon: 'weather-clear-night-symbolic', fn: () => { this.close() Util.spawn(['systemctl', 'suspend']) }, }, { label: _('Restart'), icon: 'system-reboot-symbolic', fn: () => Util.spawn(['gnome-session-quit', '--reboot']), }, { label: _('Shut down'), icon: 'system-shutdown-symbolic', fn: () => Util.spawn(['gnome-session-quit', '--power-off']), }, ]) { let btn = new St.Button({ style_class: 'win10-power-item', x_align: Clutter.ActorAlign.FILL, x_expand: true, can_focus: true, }) let box = new St.BoxLayout() box.add_child( new St.Icon({ icon_name: icon, icon_size: 16, style_class: 'win10-power-icon', }), ) box.add_child( new St.Label({ text: label, style_class: 'win10-power-label', y_align: Clutter.ActorAlign.CENTER, }), ) btn.set_child(box) btn.connect('clicked', () => { popup.destroy() fn() }) popup.add_child(btn) } Main.uiGroup.add_child(popup) this._powerPopup = popup // Position above power button after allocation let idleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { idleId = 0 if (!popup.get_parent()) return GLib.SOURCE_REMOVE let [bx, by] = powerBtn.get_transformed_position() popup.set_position( Math.round(bx + powerBtn.width - popup.width), Math.round(by - popup.height), ) return GLib.SOURCE_REMOVE }) let grabDisconnected = false let grabId = global.stage.connect('captured-event', (s, e) => { if (e.type() === Clutter.EventType.BUTTON_PRESS) { let src = e.get_source() if (!popup.contains(src) && src !== powerBtn) { grabDisconnected = true global.stage.disconnect(grabId) if (popup.get_parent()) popup.destroy() } } return Clutter.EVENT_PROPAGATE }) popup.connect('destroy', () => { if (idleId) { GLib.source_remove(idleId) idleId = 0 } if (!grabDisconnected) { grabDisconnected = true global.stage.disconnect(grabId) } this._powerPopup = null }) } _populatePinnedGrid() { this._pinnedGrid.remove_all_children() let favorites = AppFavorites.getAppFavorites().getFavorites() let favIds = new Set(favorites.map(a => a.get_id())) let others = Gio.AppInfo.get_all() .filter(info => info.should_show() && !favIds.has(info.get_id())) .map(info => this._appSystem.lookup_app(info.get_id())) .filter(app => app !== null) .sort((a, b) => a.get_name().localeCompare(b.get_name())) let apps = [...favorites, ...others] let row = null apps.forEach((app, i) => { if (i % GRID_COLUMNS === 0) { row = new St.BoxLayout({ x_expand: true }) this._pinnedGrid.add_child(row) } row.add_child(this._makeGridButton(app)) }) } _populateAllAppsList() { this._allAppsList.remove_all_children() let apps = Gio.AppInfo.get_all() .filter(info => info.should_show()) .map(info => this._appSystem.lookup_app(info.get_id())) .filter(app => app !== null) .sort((a, b) => a.get_name().localeCompare(b.get_name())) let currentLetter = null for (let app of apps) { let firstChar = (app.get_name()[0] || '#').toUpperCase() if (firstChar !== currentLetter) { currentLetter = firstChar this._allAppsList.add_child( new St.Label({ text: firstChar, style_class: 'win10-alpha-sep' }), ) } this._allAppsList.add_child(this._makeListButton(app)) } } _makeGridButton(app) { let btn = new St.Button({ style_class: 'win10-grid-app-btn', width: CELL_SIZE, height: CELL_SIZE, reactive: true, track_hover: true, can_focus: true, }) let box = new St.BoxLayout({ vertical: true, x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER, }) box.add_child(app.create_icon_texture(APP_ICON_SIZE)) let label = new St.Label({ text: app.get_name(), style_class: 'win10-grid-app-label', x_align: Clutter.ActorAlign.CENTER, }) label.clutter_text.ellipsize = Pango.EllipsizeMode.END label.clutter_text.line_wrap = false box.add_child(label) btn.set_child(box) btn.connect('clicked', () => { this.close() app.activate() }) return btn } _makeListButton(app) { let btn = new St.Button({ style_class: 'win10-list-app-btn', x_expand: true, x_align: Clutter.ActorAlign.FILL, reactive: true, track_hover: true, can_focus: true, }) let box = new St.BoxLayout({ x_expand: true, y_align: Clutter.ActorAlign.CENTER, }) let icon = app.create_icon_texture(24) box.add_child(icon) box.add_child( new St.Label({ text: app.get_name(), style_class: 'win10-list-app-label', y_align: Clutter.ActorAlign.CENTER, x_expand: true, }), ) btn.set_child(box) btn.connect('clicked', () => { this.close() app.activate() }) return btn } _showView(which) { this._pinnedView.visible = which === 'pinned' this._allAppsView.visible = which === 'all' this._searchView.visible = which === 'search' if (which === 'all' && this._allAppsList.get_n_children() === 0) { this._populateAllAppsList() } } _onSearchTextChanged() { this._timeoutsHandler.add(['searchDebounce', 150, () => { let query = this._searchEntry.get_text().trim().toLowerCase() if (query.length === 0) this._showView('pinned') else this._doSearch(query) }]) } _doSearch(query) { this._searchList.remove_all_children() let apps = Gio.AppInfo.get_all() .filter(info => { if (!info.should_show()) return false let name = info.get_name().toLowerCase() let desc = (info.get_description() || '').toLowerCase() return name.includes(query) || desc.includes(query) }) .map(info => this._appSystem.lookup_app(info.get_id())) .filter(app => app !== null) .sort((a, b) => a.get_name().localeCompare(b.get_name())) if (apps.length === 0) { this._searchList.add_child( new St.Label({ text: _('No results found'), style_class: 'win10-no-results', x_align: Clutter.ActorAlign.CENTER, }), ) } else { for (let app of apps) { this._searchList.add_child(this._makeListButton(app)) } } this._showView('search') } open(sourceActor) { if (this.isOpen) return this.isOpen = true this._sourceActor = sourceActor || null this.remove_all_transitions() this._searchEntry.set_text('') this._populatePinnedGrid() this._showView('pinned') this._updatePosition(sourceActor) this.visible = true this.opacity = 0 this.translation_y = 10 this.ease({ opacity: 255, translation_y: 0, duration: 180, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }) // Defer captured-event + focus until after the triggering button-press // finishes propagating, so we don't catch our own opening click. GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { if (!this.isOpen) return GLib.SOURCE_REMOVE this._captureId = global.stage.connect( 'captured-event', this._onCapturedEvent.bind(this), ) // Close when an app window gains focus (Wayland: window clicks bypass stage) this._focusId = global.display.connect( 'notify::focus-window', () => { if (this.isOpen) this.close() }, ) this._searchEntry.grab_key_focus() return GLib.SOURCE_REMOVE }) } close() { if (!this.isOpen) return this.isOpen = false if (this._captureId) { global.stage.disconnect(this._captureId) this._captureId = 0 } if (this._focusId) { global.display.disconnect(this._focusId) this._focusId = 0 } this._sourceActor = null this.remove_all_transitions() if (this._powerPopup) { this._powerPopup.destroy() this._powerPopup = null } this.ease({ opacity: 0, translation_y: 10, duration: 120, mode: Clutter.AnimationMode.EASE_IN_QUAD, onComplete: () => { if (!this.isOpen) this.visible = false }, }) } toggle(sourceActor) { if (this.isOpen) this.close() else this.open(sourceActor) } _updatePosition(sourceActor) { let monitor = this._dtpPanel.monitor let panelPos = this._dtpPanel.geom.position let menuX, menuY if (sourceActor) { let [ax, ay] = sourceActor.get_transformed_position() let aw = sourceActor.width let ah = sourceActor.height switch (panelPos) { case St.Side.BOTTOM: menuX = ax menuY = ay - MENU_HEIGHT break case St.Side.TOP: menuX = ax menuY = ay + ah break case St.Side.LEFT: menuX = ax + aw menuY = ay break case St.Side.RIGHT: menuX = ax - MENU_WIDTH menuY = ay break default: menuX = ax menuY = ay - MENU_HEIGHT } } else { menuX = monitor.x + 4 menuY = monitor.y + monitor.height - MENU_HEIGHT } // Clamp to monitor bounds menuX = Math.max( monitor.x + 4, Math.min(menuX, monitor.x + monitor.width - MENU_WIDTH - 4), ) menuY = Math.max( monitor.y + 4, Math.min(menuY, monitor.y + monitor.height - MENU_HEIGHT - 4), ) this.set_size(MENU_WIDTH, MENU_HEIGHT) this._outer.set_size(MENU_WIDTH, MENU_HEIGHT) this.set_position(Math.round(menuX), Math.round(menuY)) } _onCapturedEvent(_actor, event) { if (!this.isOpen) return Clutter.EVENT_PROPAGATE if (event.type() === Clutter.EventType.BUTTON_PRESS) { let [ex, ey] = event.get_coords() let [mx, my] = this.get_transformed_position() if (ex < mx || ex > mx + this.width || ey < my || ey > my + this.height) { // Let the source actor (show-apps button) handle its own toggle let src = event.get_source() if (this._sourceActor && this._sourceActor.contains(src)) return Clutter.EVENT_PROPAGATE this.close() } } else if (event.type() === Clutter.EventType.KEY_PRESS) { if (event.get_key_symbol() === Clutter.KEY_Escape) { this.close() return Clutter.EVENT_STOP } } return Clutter.EVENT_PROPAGATE } destroy() { if (this._captureId) { global.stage.disconnect(this._captureId) this._captureId = 0 } if (this._focusId) { global.display.disconnect(this._focusId) this._focusId = 0 } if (this._powerPopup) { this._powerPopup.destroy() this._powerPopup = null } if (this._customStylesheet) { let theme = St.ThemeContext.get_for_stage(global.stage).get_theme() theme.unload_stylesheet(this._customStylesheet) this._customStylesheet = null } this._timeoutsHandler.destroy() this._signalsHandler.destroy() super.destroy() } }, )