Files
system-taskbar/src/startMenu.js
2026-04-04 06:01:59 -07:00

743 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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()
}
},
)