// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- import Clutter from 'gi://Clutter'; import Cogl from 'gi://Cogl'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Graphene from 'gi://Graphene'; import Meta from 'gi://Meta'; import Pango from 'gi://Pango'; import Shell from 'gi://Shell'; import St from 'gi://St'; import * as Signals from '../misc/signals.js'; import System from 'system'; import * as History from '../misc/history.js'; import {ExtensionState} from '../misc/extensionUtils.js'; import * as PopupMenu from './popupMenu.js'; import * as ShellEntry from './shellEntry.js'; import * as Main from './main.js'; import * as JsParse from '../misc/jsParse.js'; const CHEVRON = '>>> '; /* Imports...feel free to add here as needed */ const commandHeader = ` const {Clutter, Gio, GLib, GObject, Meta, Shell, St} = imports.gi; const Main = await import('resource:///org/gnome/shell/ui/main.js'); /* Utility functions...we should probably be able to use these * in the shell core code too. */ const stage = global.stage; /* Special lookingGlass functions */ const inspect = Main.lookingGlass.inspect.bind(Main.lookingGlass); const it = Main.lookingGlass.getIt(); const r = Main.lookingGlass.getResult.bind(Main.lookingGlass); `; const AsyncFunction = async function () {}.constructor; const HISTORY_KEY = 'looking-glass-history'; // Time between tabs for them to count as a double-tab event const AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500; const AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 200; const AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords(); const LG_ANIMATION_TIME = 500; const CLUTTER_DEBUG_FLAG_CATEGORIES = new Map([ // Paint debugging can easily result in a non-responsive session ['DebugFlag', {argPos: 0, exclude: ['PAINT']}], ['DrawDebugFlag', {argPos: 1, exclude: []}], // Exluded due to the only current option likely to result in shooting ones // foot // ['PickDebugFlag', { argPos: 2, exclude: [] }], ]); function _getAutoCompleteGlobalKeywords() { const keywords = ['true', 'false', 'null', 'new']; // Don't add the private properties of globalThis (i.e., ones starting with '_') const windowProperties = Object.getOwnPropertyNames(globalThis).filter( a => a.charAt(0) !== '_'); const headerProperties = JsParse.getDeclaredConstants(commandHeader); return keywords.concat(windowProperties).concat(headerProperties); } class AutoComplete extends Signals.EventEmitter { constructor(entry) { super(); this._entry = entry; this._entry.connect('key-press-event', this._entryKeyPressEvent.bind(this)); this._lastTabTime = global.get_current_time(); } _processCompletionRequest(event) { if (event.completions.length === 0) return; // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string; // multiple matches + double tab = emit a suggest event with all possible options if (event.completions.length === 1) { this.additionalCompletionText(event.completions[0], event.attrHead); this.emit('completion', {completion: event.completions[0], type: 'whole-word'}); } else if (event.completions.length > 1 && event.tabType === 'single') { let commonPrefix = JsParse.getCommonPrefix(event.completions); if (commonPrefix.length > 0) { this.additionalCompletionText(commonPrefix, event.attrHead); this.emit('completion', {completion: commonPrefix, type: 'prefix'}); this.emit('suggest', {completions: event.completions}); } } else if (event.completions.length > 1 && event.tabType === 'double') { this.emit('suggest', {completions: event.completions}); } } async _handleCompletions(text, time) { const [completions, attrHead] = await JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS); const tabType = (time - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY ? 'double' : 'single'; this._processCompletionRequest({ tabType, completions, attrHead, }); this._lastTabTime = time; } _entryKeyPressEvent(actor, event) { let cursorPos = this._entry.clutter_text.get_cursor_position(); let text = this._entry.get_text(); if (cursorPos !== -1) text = text.slice(0, cursorPos); if (event.get_key_symbol() === Clutter.KEY_Tab) this._handleCompletions(text, event.get_time()).catch(logError); return Clutter.EVENT_PROPAGATE; } // Insert characters of text not already included in head at cursor position. i.e., if text="abc" and head="a", // the string "bc" will be appended to this._entry additionalCompletionText(text, head) { let additionalCompletionText = text.slice(head.length); let cursorPos = this._entry.clutter_text.get_cursor_position(); this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos); } } const Notebook = GObject.registerClass({ Signals: {'selection': {param_types: [Clutter.Actor.$gtype]}}, }, class Notebook extends St.BoxLayout { _init() { super._init({ vertical: true, y_expand: true, }); this.tabControls = new St.BoxLayout({style_class: 'labels'}); this._selectedIndex = -1; this._tabs = []; } appendPage(name, child) { const labelBox = new St.BoxLayout({ style_class: 'notebook-tab', reactive: true, track_hover: true, }); let label = new St.Button({label: name}); label.connect('clicked', () => { this.selectChild(child); return true; }); labelBox.add_child(label); this.tabControls.add(labelBox); let scrollview = new St.ScrollView({y_expand: true}); scrollview.get_hscroll_bar().hide(); scrollview.add_actor(child); const tabData = { child, labelBox, label, scrollView: scrollview, _scrollToBottom: false, }; this._tabs.push(tabData); scrollview.hide(); this.add_child(scrollview); let vAdjust = scrollview.vscroll.adjustment; vAdjust.connect('changed', () => this._onAdjustScopeChanged(tabData)); vAdjust.connect('notify::value', () => this._onAdjustValueChanged(tabData)); if (this._selectedIndex === -1) this.selectIndex(0); } _unselect() { if (this._selectedIndex < 0) return; let tabData = this._tabs[this._selectedIndex]; tabData.labelBox.remove_style_pseudo_class('selected'); tabData.scrollView.hide(); this._selectedIndex = -1; } selectIndex(index) { if (index === this._selectedIndex) return; if (index < 0) { this._unselect(); this.emit('selection', null); return; } // Focus the new tab before unmapping the old one let tabData = this._tabs[index]; if (!tabData.scrollView.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) this.grab_key_focus(); this._unselect(); tabData.labelBox.add_style_pseudo_class('selected'); tabData.scrollView.show(); this._selectedIndex = index; this.emit('selection', tabData.child); } selectChild(child) { if (child == null) { this.selectIndex(-1); } else { for (let i = 0; i < this._tabs.length; i++) { let tabData = this._tabs[i]; if (tabData.child === child) { this.selectIndex(i); return; } } } } scrollToBottom(index) { let tabData = this._tabs[index]; tabData._scrollToBottom = true; } _onAdjustValueChanged(tabData) { let vAdjust = tabData.scrollView.vscroll.adjustment; if (vAdjust.value < (vAdjust.upper - vAdjust.lower - 0.5)) tabData._scrolltoBottom = false; } _onAdjustScopeChanged(tabData) { if (!tabData._scrollToBottom) return; let vAdjust = tabData.scrollView.vscroll.adjustment; vAdjust.value = vAdjust.upper - vAdjust.page_size; } nextTab() { let nextIndex = this._selectedIndex; if (nextIndex < this._tabs.length - 1) ++nextIndex; this.selectIndex(nextIndex); } prevTab() { let prevIndex = this._selectedIndex; if (prevIndex > 0) --prevIndex; this.selectIndex(prevIndex); } }); function objectToString(o) { if (typeof o === typeof objectToString) { // special case this since the default is way, way too verbose return ''; } else if (o && o.toString === undefined) { // eeks, something unprintable. we'll have to guess, probably a module return typeof o === 'object' && !(o instanceof Object) ? '' : ''; } else { return `${o}`; } } const ObjLink = GObject.registerClass( class ObjLink extends St.Button { _init(lookingGlass, o, title) { let text; if (title) text = title; else text = objectToString(o); text = GLib.markup_escape_text(text, -1); super._init({ reactive: true, track_hover: true, style_class: 'shell-link', label: text, x_align: Clutter.ActorAlign.START, }); this.get_child().single_line_mode = true; this._obj = o; this._lookingGlass = lookingGlass; } vfunc_clicked() { this._lookingGlass.inspectObject(this._obj, this); } }); const Result = GObject.registerClass( class Result extends St.BoxLayout { _init(lookingGlass, command, o, index) { super._init({vertical: true}); this.index = index; this.o = o; this._lookingGlass = lookingGlass; let cmdTxt = new St.Label({text: command}); cmdTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; this.add(cmdTxt); let box = new St.BoxLayout({}); this.add(box); let resultTxt = new St.Label({text: `r(${index}) = `}); resultTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; box.add(resultTxt); let objLink = new ObjLink(this._lookingGlass, o); box.add(objLink); } }); const WindowList = GObject.registerClass({ }, class WindowList extends St.BoxLayout { _init(lookingGlass) { super._init({name: 'Windows', vertical: true, style: 'spacing: 8px'}); let tracker = Shell.WindowTracker.get_default(); this._updateId = Main.initializeDeferredWork(this, this._updateWindowList.bind(this)); global.display.connect('window-created', this._updateWindowList.bind(this)); tracker.connect('tracked-windows-changed', this._updateWindowList.bind(this)); this._lookingGlass = lookingGlass; } _updateWindowList() { if (!this._lookingGlass.isOpen) return; this.destroy_all_children(); let windows = global.get_window_actors(); let tracker = Shell.WindowTracker.get_default(); for (let i = 0; i < windows.length; i++) { let metaWindow = windows[i].metaWindow; // Avoid multiple connections if (!metaWindow._lookingGlassManaged) { metaWindow.connect('unmanaged', this._updateWindowList.bind(this)); metaWindow._lookingGlassManaged = true; } let box = new St.BoxLayout({vertical: true}); this.add(box); let windowLink = new ObjLink(this._lookingGlass, metaWindow, metaWindow.title); box.add_child(windowLink); let propsBox = new St.BoxLayout({vertical: true, style: 'padding-left: 6px;'}); box.add(propsBox); propsBox.add(new St.Label({text: `wmclass: ${metaWindow.get_wm_class()}`})); let app = tracker.get_window_app(metaWindow); if (app != null && !app.is_window_backed()) { let icon = app.create_icon_texture(22); let propBox = new St.BoxLayout({style: 'spacing: 6px; '}); propsBox.add(propBox); propBox.add_child(new St.Label({text: 'app: '})); let appLink = new ObjLink(this._lookingGlass, app, app.get_id()); propBox.add_child(appLink); propBox.add_child(icon); } else { propsBox.add(new St.Label({text: ''})); } } } update() { this._updateWindowList(); } }); const ObjInspector = GObject.registerClass( class ObjInspector extends St.ScrollView { _init(lookingGlass) { super._init({ pivot_point: new Graphene.Point({x: 0.5, y: 0.5}), }); this._obj = null; this._previousObj = null; this._parentList = []; this.get_hscroll_bar().hide(); this._container = new St.BoxLayout({ name: 'LookingGlassPropertyInspector', style_class: 'lg-dialog', vertical: true, x_expand: true, y_expand: true, }); this.add_actor(this._container); this._lookingGlass = lookingGlass; } selectObject(obj, skipPrevious) { if (!skipPrevious) this._previousObj = this._obj; else this._previousObj = null; this._obj = obj; this._container.destroy_all_children(); let hbox = new St.BoxLayout({style_class: 'lg-obj-inspector-title'}); this._container.add_actor(hbox); let label = new St.Label({ text: `Inspecting: ${typeof obj}: ${objectToString(obj)}`, x_expand: true, }); label.single_line_mode = true; hbox.add_child(label); let button = new St.Button({label: 'Insert', style_class: 'lg-obj-inspector-button'}); button.connect('clicked', this._onInsert.bind(this)); hbox.add(button); if (this._previousObj != null) { button = new St.Button({label: 'Back', style_class: 'lg-obj-inspector-button'}); button.connect('clicked', this._onBack.bind(this)); hbox.add(button); } button = new St.Button({ style_class: 'window-close', icon_name: 'window-close-symbolic', }); button.connect('clicked', this.close.bind(this)); hbox.add(button); if (typeof obj === typeof {}) { let properties = []; for (let propName in obj) properties.push(propName); properties.sort(); for (let i = 0; i < properties.length; i++) { let propName = properties[i]; let link; try { let prop = obj[propName]; link = new ObjLink(this._lookingGlass, prop); } catch (e) { link = new St.Label({text: ''}); } let box = new St.BoxLayout(); box.add(new St.Label({text: `${propName}: `})); box.add(link); this._container.add_actor(box); } } } open(sourceActor) { if (this._open) return; const grab = Main.pushModal(this, {actionMode: Shell.ActionMode.LOOKING_GLASS}); if (grab.get_seat_state() !== Clutter.GrabState.ALL) { Main.popModal(grab); return; } this._grab = grab; this._previousObj = null; this._open = true; this.show(); if (sourceActor) { this.set_scale(0, 0); this.ease({ scale_x: 1, scale_y: 1, mode: Clutter.AnimationMode.EASE_OUT_QUAD, duration: 200, }); } else { this.set_scale(1, 1); } } close() { if (!this._open) return; Main.popModal(this._grab); this._grab = null; this._open = false; this.hide(); this._previousObj = null; this._obj = null; } vfunc_key_press_event(event) { const symbol = event.get_key_symbol(); if (symbol === Clutter.KEY_Escape) { this.close(); return Clutter.EVENT_STOP; } return super.vfunc_key_press_event(event); } _onInsert() { let obj = this._obj; this.close(); this._lookingGlass.insertObject(obj); } _onBack() { this.selectObject(this._previousObj, true); } }); const RedBorderEffect = GObject.registerClass( class RedBorderEffect extends Clutter.Effect { _init() { super._init(); this._pipeline = null; } vfunc_paint_node(node, paintContext) { let actor = this.get_actor(); const actorNode = new Clutter.ActorNode(actor, -1); node.add_child(actorNode); if (!this._pipeline) { const framebuffer = paintContext.get_framebuffer(); const coglContext = framebuffer.get_context(); let color = new Cogl.Color(); color.init_from_4ub(0xff, 0, 0, 0xc4); this._pipeline = new Cogl.Pipeline(coglContext); this._pipeline.set_color(color); } let alloc = actor.get_allocation_box(); let width = 2; const pipelineNode = new Clutter.PipelineNode(this._pipeline); pipelineNode.set_name('Red Border'); node.add_child(pipelineNode); const box = new Clutter.ActorBox(); // clockwise order box.set_origin(0, 0); box.set_size(alloc.get_width(), width); pipelineNode.add_rectangle(box); box.set_origin(alloc.get_width() - width, width); box.set_size(width, alloc.get_height() - width); pipelineNode.add_rectangle(box); box.set_origin(0, alloc.get_height() - width); box.set_size(alloc.get_width() - width, width); pipelineNode.add_rectangle(box); box.set_origin(0, width); box.set_size(width, alloc.get_height() - width * 2); pipelineNode.add_rectangle(box); } }); const Inspector = GObject.registerClass({ Signals: { 'closed': {}, 'target': {param_types: [Clutter.Actor.$gtype, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE]}, }, }, class Inspector extends Clutter.Actor { _init(lookingGlass) { super._init({width: 0, height: 0}); Main.uiGroup.add_actor(this); const eventHandler = new St.BoxLayout({ name: 'LookingGlassDialog', vertical: false, reactive: true, }); this._eventHandler = eventHandler; this.add_actor(eventHandler); this._displayText = new St.Label({x_expand: true}); eventHandler.add_child(this._displayText); eventHandler.connect('key-press-event', this._onKeyPressEvent.bind(this)); eventHandler.connect('button-press-event', this._onButtonPressEvent.bind(this)); eventHandler.connect('scroll-event', this._onScrollEvent.bind(this)); eventHandler.connect('motion-event', this._onMotionEvent.bind(this)); this._grab = global.stage.grab(eventHandler); // this._target is the actor currently shown by the inspector. // this._pointerTarget is the actor directly under the pointer. // Normally these are the same, but if you use the scroll wheel // to drill down, they'll diverge until you either scroll back // out, or move the pointer outside of _pointerTarget. this._target = null; this._pointerTarget = null; this._lookingGlass = lookingGlass; } vfunc_allocate(box) { this.set_allocation(box); if (!this._eventHandler) return; let primary = Main.layoutManager.primaryMonitor; let [, , natWidth, natHeight] = this._eventHandler.get_preferred_size(); let childBox = new Clutter.ActorBox(); childBox.x1 = primary.x + Math.floor((primary.width - natWidth) / 2); childBox.x2 = childBox.x1 + natWidth; childBox.y1 = primary.y + Math.floor((primary.height - natHeight) / 2); childBox.y2 = childBox.y1 + natHeight; this._eventHandler.allocate(childBox); } _close() { if (this._grab) { this._grab.dismiss(); this._grab = null; } this._eventHandler.destroy(); this._eventHandler = null; this.emit('closed'); } _onKeyPressEvent(actor, event) { if (event.get_key_symbol() === Clutter.KEY_Escape) this._close(); return Clutter.EVENT_STOP; } _onButtonPressEvent(actor, event) { if (this._target) { let [stageX, stageY] = event.get_coords(); this.emit('target', this._target, stageX, stageY); } this._close(); return Clutter.EVENT_STOP; } _onScrollEvent(actor, event) { switch (event.get_scroll_direction()) { case Clutter.ScrollDirection.UP: { // select parent let parent = this._target.get_parent(); if (parent != null) { this._target = parent; this._update(event); } break; } case Clutter.ScrollDirection.DOWN: // select child if (this._target !== this._pointerTarget) { let child = this._pointerTarget; while (child) { let parent = child.get_parent(); if (parent === this._target) break; child = parent; } if (child) { this._target = child; this._update(event); } } break; default: break; } return Clutter.EVENT_STOP; } _onMotionEvent(actor, event) { this._update(event); return Clutter.EVENT_STOP; } _update(event) { let [stageX, stageY] = event.get_coords(); let target = global.stage.get_actor_at_pos( Clutter.PickMode.ALL, stageX, stageY); if (target !== this._pointerTarget) this._target = target; this._pointerTarget = target; let position = `[inspect x: ${stageX} y: ${stageY}]`; this._displayText.text = ''; this._displayText.text = `${position} ${this._target}`; this._lookingGlass.setBorderPaintTarget(this._target); } }); const Extensions = GObject.registerClass({ }, class Extensions extends St.BoxLayout { _init(lookingGlass) { super._init({vertical: true, name: 'lookingGlassExtensions'}); this._lookingGlass = lookingGlass; this._noExtensions = new St.Label({ style_class: 'lg-extensions-none', text: _('No extensions installed'), }); this._numExtensions = 0; this._extensionsList = new St.BoxLayout({ vertical: true, style_class: 'lg-extensions-list', }); this._extensionsList.add(this._noExtensions); this.add(this._extensionsList); Main.extensionManager.getUuids().forEach(uuid => { this._loadExtension(null, uuid); }); Main.extensionManager.connect('extension-loaded', this._loadExtension.bind(this)); } _loadExtension(o, uuid) { let extension = Main.extensionManager.lookup(uuid); // There can be cases where we create dummy extension metadata // that's not really a proper extension. Don't bother with these. if (!extension.metadata.name) return; let extensionDisplay = this._createExtensionDisplay(extension); if (this._numExtensions === 0) this._extensionsList.remove_actor(this._noExtensions); this._numExtensions++; const {name} = extension.metadata; const pos = [...this._extensionsList].findIndex( dsp => dsp._extension.metadata.name.localeCompare(name) > 0); this._extensionsList.insert_child_at_index(extensionDisplay, pos); } _onViewSource(actor) { let extension = actor._extension; let uri = extension.dir.get_uri(); Gio.app_info_launch_default_for_uri(uri, global.create_app_launch_context(0, -1)); this._lookingGlass.close(); } _onWebPage(actor) { let extension = actor._extension; Gio.app_info_launch_default_for_uri(extension.metadata.url, global.create_app_launch_context(0, -1)); this._lookingGlass.close(); } _onViewErrors(actor) { let extension = actor._extension; let shouldShow = !actor._isShowing; if (shouldShow) { let errors = extension.errors; let errorDisplay = new St.BoxLayout({vertical: true}); if (errors && errors.length) { for (let i = 0; i < errors.length; i++) errorDisplay.add(new St.Label({text: errors[i]})); } else { /* Translators: argument is an extension UUID. */ let message = _('%s has not emitted any errors.').format(extension.uuid); errorDisplay.add(new St.Label({text: message})); } actor._errorDisplay = errorDisplay; actor._parentBox.add(errorDisplay); actor.label = _('Hide Errors'); } else { actor._errorDisplay.destroy(); actor._errorDisplay = null; actor.label = _('Show Errors'); } actor._isShowing = shouldShow; } _stateToString(extensionState) { switch (extensionState) { case ExtensionState.ENABLED: return _('Enabled'); case ExtensionState.DISABLED: case ExtensionState.INITIALIZED: return _('Disabled'); case ExtensionState.ERROR: return _('Error'); case ExtensionState.OUT_OF_DATE: return _('Out of date'); case ExtensionState.DOWNLOADING: return _('Downloading'); case ExtensionState.DISABLING: return _('Disabling'); case ExtensionState.ENABLING: return _('Enabling'); } return 'Unknown'; // Not translated, shouldn't appear } _createExtensionDisplay(extension) { let box = new St.BoxLayout({style_class: 'lg-extension', vertical: true}); box._extension = extension; let name = new St.Label({ style_class: 'lg-extension-name', text: extension.metadata.name, x_expand: true, }); box.add_child(name); let description = new St.Label({ style_class: 'lg-extension-description', text: extension.metadata.description || 'No description', x_expand: true, }); box.add_child(description); let metaBox = new St.BoxLayout({style_class: 'lg-extension-meta'}); box.add(metaBox); const state = new St.Label({ style_class: 'lg-extension-state', text: this._stateToString(extension.state), }); metaBox.add(state); const viewsource = new St.Button({ reactive: true, track_hover: true, style_class: 'shell-link', label: _('View Source'), }); viewsource._extension = extension; viewsource.connect('clicked', this._onViewSource.bind(this)); metaBox.add(viewsource); if (extension.metadata.url) { const webpage = new St.Button({ reactive: true, track_hover: true, style_class: 'shell-link', label: _('Web Page'), }); webpage._extension = extension; webpage.connect('clicked', this._onWebPage.bind(this)); metaBox.add(webpage); } const viewerrors = new St.Button({ reactive: true, track_hover: true, style_class: 'shell-link', label: _('Show Errors'), }); viewerrors._extension = extension; viewerrors._parentBox = box; viewerrors._isShowing = false; viewerrors.connect('clicked', this._onViewErrors.bind(this)); metaBox.add(viewerrors); return box; } }); const ActorLink = GObject.registerClass({ Signals: { 'inspect-actor': {}, }, }, class ActorLink extends St.Button { _init(actor) { this._arrow = new St.Icon({ icon_name: 'pan-end-symbolic', icon_size: 8, x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER, pivot_point: new Graphene.Point({x: 0.5, y: 0.5}), }); const label = new St.Label({ text: actor.toString(), x_align: Clutter.ActorAlign.START, }); const inspectButton = new St.Button({ icon_name: 'insert-object-symbolic', reactive: true, x_expand: true, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, }); inspectButton.connect('clicked', () => this.emit('inspect-actor')); const box = new St.BoxLayout(); box.add_child(this._arrow); box.add_child(label); box.add_child(inspectButton); super._init({ reactive: true, track_hover: true, toggle_mode: true, style_class: 'actor-link', child: box, x_align: Clutter.ActorAlign.START, }); this._actor = actor; } vfunc_clicked() { this._arrow.ease({ rotation_angle_z: this.checked ? 90 : 0, duration: 250, }); } }); const ActorTreeViewer = GObject.registerClass( class ActorTreeViewer extends St.BoxLayout { _init(lookingGlass) { super._init(); this._lookingGlass = lookingGlass; this._actorData = new Map(); } _showActorChildren(actor) { const data = this._actorData.get(actor); if (!data || data.visible) return; data.visible = true; data.actorAddedId = actor.connect('actor-added', (container, child) => { this._addActor(data.children, child); }); data.actorRemovedId = actor.connect('actor-removed', (container, child) => { this._removeActor(child); }); for (let child of actor) this._addActor(data.children, child); } _hideActorChildren(actor) { const data = this._actorData.get(actor); if (!data || !data.visible) return; for (let child of actor) this._removeActor(child); data.visible = false; if (data.actorAddedId > 0) { actor.disconnect(data.actorAddedId); data.actorAddedId = 0; } if (data.actorRemovedId > 0) { actor.disconnect(data.actorRemovedId); data.actorRemovedId = 0; } data.children.remove_all_children(); } _addActor(container, actor) { if (this._actorData.has(actor)) return; if (actor === this._lookingGlass) return; const button = new ActorLink(actor); button.connect('notify::checked', () => { this._lookingGlass.setBorderPaintTarget(actor); if (button.checked) this._showActorChildren(actor); else this._hideActorChildren(actor); }); button.connect('inspect-actor', () => { this._lookingGlass.inspectObject(actor, button); }); const mainContainer = new St.BoxLayout({vertical: true}); const childrenContainer = new St.BoxLayout({ vertical: true, style: 'padding: 0 0 0 18px', }); mainContainer.add_child(button); mainContainer.add_child(childrenContainer); this._actorData.set(actor, { button, container: mainContainer, children: childrenContainer, visible: false, actorAddedId: 0, actorRemovedId: 0, actorDestroyedId: actor.connect('destroy', () => this._removeActor(actor)), }); let belowChild = null; const nextSibling = actor.get_next_sibling(); if (nextSibling && this._actorData.has(nextSibling)) belowChild = this._actorData.get(nextSibling).container; container.insert_child_above(mainContainer, belowChild); } _removeActor(actor) { const data = this._actorData.get(actor); if (!data) return; for (let child of actor) this._removeActor(child); if (data.actorAddedId > 0) { actor.disconnect(data.actorAddedId); data.actorAddedId = 0; } if (data.actorRemovedId > 0) { actor.disconnect(data.actorRemovedId); data.actorRemovedId = 0; } if (data.actorDestroyedId > 0) { actor.disconnect(data.actorDestroyedId); data.actorDestroyedId = 0; } data.container.destroy(); this._actorData.delete(actor); } vfunc_map() { super.vfunc_map(); this._addActor(this, global.stage); } vfunc_unmap() { super.vfunc_unmap(); this._removeActor(global.stage); } }); const DebugFlag = GObject.registerClass({ GTypeFlags: GObject.TypeFlags.ABSTRACT, }, class DebugFlag extends St.Button { _init(label) { const box = new St.BoxLayout(); const flagLabel = new St.Label({ text: label, x_expand: true, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, }); box.add_child(flagLabel); this._flagSwitch = new PopupMenu.Switch(false); this._stateHandler = this._flagSwitch.connect('notify::state', () => { if (this._flagSwitch.state) this._enable(); else this._disable(); }); // Update state whenever the switch is mapped, because most debug flags // don't have a way of notifying us of changes. this._flagSwitch.connect('notify::mapped', () => { if (!this._flagSwitch.is_mapped()) return; const state = this._isEnabled(); if (state === this._flagSwitch.state) return; this._flagSwitch.block_signal_handler(this._stateHandler); this._flagSwitch.state = state; this._flagSwitch.unblock_signal_handler(this._stateHandler); }); box.add_child(this._flagSwitch); super._init({ style_class: 'lg-debug-flag-button', can_focus: true, toggleMode: true, child: box, label_actor: flagLabel, y_align: Clutter.ActorAlign.CENTER, }); this.connect('clicked', () => this._flagSwitch.toggle()); } _isEnabled() { throw new Error('Method not implemented'); } _enable() { throw new Error('Method not implemented'); } _disable() { throw new Error('Method not implemented'); } }); const ClutterDebugFlag = GObject.registerClass( class ClutterDebugFlag extends DebugFlag { _init(categoryName, flagName) { super._init(flagName); this._argPos = CLUTTER_DEBUG_FLAG_CATEGORIES.get(categoryName).argPos; this._enumValue = Clutter[categoryName][flagName]; } _isEnabled() { const enabledFlags = Meta.get_clutter_debug_flags(); return !!(enabledFlags[this._argPos] & this._enumValue); } _getArgs() { const args = [0, 0, 0]; args[this._argPos] = this._enumValue; return args; } _enable() { Meta.add_clutter_debug_flags(...this._getArgs()); } _disable() { Meta.remove_clutter_debug_flags(...this._getArgs()); } }); const MutterPaintDebugFlag = GObject.registerClass( class MutterPaintDebugFlag extends DebugFlag { _init(flagName) { super._init(flagName); this._enumValue = Meta.DebugPaintFlag[flagName]; } _isEnabled() { return !!(Meta.get_debug_paint_flags() & this._enumValue); } _enable() { Meta.add_debug_paint_flag(this._enumValue); } _disable() { Meta.remove_debug_paint_flag(this._enumValue); } }); const MutterTopicDebugFlag = GObject.registerClass( class MutterTopicDebugFlag extends DebugFlag { _init(flagName) { super._init(flagName); this._enumValue = Meta.DebugTopic[flagName]; } _isEnabled() { return Meta.is_topic_enabled(this._enumValue); } _enable() { Meta.add_verbose_topic(this._enumValue); } _disable() { Meta.remove_verbose_topic(this._enumValue); } }); const UnsafeModeDebugFlag = GObject.registerClass( class UnsafeModeDebugFlag extends DebugFlag { _init() { super._init('unsafe-mode'); } _isEnabled() { return global.context.unsafe_mode; } _enable() { global.context.unsafe_mode = true; } _disable() { global.context.unsafe_mode = false; } }); const DebugFlags = GObject.registerClass( class DebugFlags extends St.BoxLayout { _init() { super._init({ name: 'lookingGlassDebugFlags', vertical: true, x_align: Clutter.ActorAlign.CENTER, }); // Clutter debug flags for (const [categoryName, props] of CLUTTER_DEBUG_FLAG_CATEGORIES.entries()) { this._addHeader(`Clutter${categoryName}`); for (const flagName of this._getFlagNames(Clutter[categoryName])) { if (props.exclude.includes(flagName)) continue; this.add_child(new ClutterDebugFlag(categoryName, flagName)); } } // Meta paint flags this._addHeader('MetaDebugPaintFlag'); for (const flagName of this._getFlagNames(Meta.DebugPaintFlag)) this.add_child(new MutterPaintDebugFlag(flagName)); // Meta debug topics this._addHeader('MetaDebugTopic'); for (const flagName of this._getFlagNames(Meta.DebugTopic)) this.add_child(new MutterTopicDebugFlag(flagName)); // MetaContext::unsafe-mode this._addHeader('MetaContext'); this.add_child(new UnsafeModeDebugFlag()); } _addHeader(title) { const header = new St.Label({ text: title, style_class: 'lg-debug-flags-header', x_align: Clutter.ActorAlign.START, }); this.add_child(header); } *_getFlagNames(enumObject) { for (const flagName of Object.getOwnPropertyNames(enumObject)) { if (typeof enumObject[flagName] !== 'number') continue; if (enumObject[flagName] <= 0) continue; yield flagName; } } }); export const LookingGlass = GObject.registerClass( class LookingGlass extends St.BoxLayout { _init() { super._init({ name: 'LookingGlassDialog', style_class: 'lg-dialog', vertical: true, visible: false, reactive: true, }); this._borderPaintTarget = null; this._redBorderEffect = new RedBorderEffect(); this._open = false; this._it = null; this._offset = 0; // Sort of magic, but...eh. this._maxItems = 150; this._interfaceSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}); this._interfaceSettings.connect('changed::monospace-font-name', this._updateFont.bind(this)); this._updateFont(); // We want it to appear to slide out from underneath the panel Main.uiGroup.add_actor(this); Main.uiGroup.set_child_below_sibling(this, Main.layoutManager.panelBox); Main.layoutManager.panelBox.connect('notify::allocation', this._queueResize.bind(this)); Main.layoutManager.keyboardBox.connect('notify::allocation', this._queueResize.bind(this)); this._objInspector = new ObjInspector(this); Main.uiGroup.add_actor(this._objInspector); this._objInspector.hide(); let toolbar = new St.BoxLayout({name: 'Toolbar'}); this.add_actor(toolbar); const inspectButton = new St.Button({ style_class: 'lg-toolbar-button', icon_name: 'find-location-symbolic', }); toolbar.add_actor(inspectButton); inspectButton.connect('clicked', () => { let inspector = new Inspector(this); inspector.connect('target', (i, target, stageX, stageY) => { this._pushResult(`inspect(${Math.round(stageX)}, ${Math.round(stageY)})`, target); }); inspector.connect('closed', () => { this.show(); global.stage.set_key_focus(this._entry); }); this.hide(); return Clutter.EVENT_STOP; }); const gcButton = new St.Button({ style_class: 'lg-toolbar-button', icon_name: 'user-trash-full-symbolic', }); toolbar.add_actor(gcButton); gcButton.connect('clicked', () => { gcButton.child.icon_name = 'user-trash-symbolic'; System.gc(); this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { gcButton.child.icon_name = 'user-trash-full-symbolic'; this._timeoutId = 0; return GLib.SOURCE_REMOVE; }); GLib.Source.set_name_by_id( this._timeoutId, '[gnome-shell] gcButton.child.icon_name = \'user-trash-full-symbolic\'' ); return Clutter.EVENT_PROPAGATE; }); let notebook = new Notebook(); this._notebook = notebook; this.add_child(notebook); let emptyBox = new St.Bin({x_expand: true}); toolbar.add_child(emptyBox); toolbar.add_actor(notebook.tabControls); this._evalBox = new St.BoxLayout({name: 'EvalBox', vertical: true}); notebook.appendPage('Evaluator', this._evalBox); this._resultsArea = new St.BoxLayout({ name: 'ResultsArea', vertical: true, y_expand: true, }); this._evalBox.add_child(this._resultsArea); this._entryArea = new St.BoxLayout({ name: 'EntryArea', y_align: Clutter.ActorAlign.END, }); this._evalBox.add_actor(this._entryArea); let label = new St.Label({text: CHEVRON}); this._entryArea.add(label); this._entry = new St.Entry({ can_focus: true, x_expand: true, }); ShellEntry.addContextMenu(this._entry); this._entryArea.add_child(this._entry); this._windowList = new WindowList(this); notebook.appendPage('Windows', this._windowList); this._extensions = new Extensions(this); notebook.appendPage('Extensions', this._extensions); this._actorTreeViewer = new ActorTreeViewer(this); notebook.appendPage('Actors', this._actorTreeViewer); this._debugFlags = new DebugFlags(); notebook.appendPage('Flags', this._debugFlags); this._entry.clutter_text.connect('activate', (o, _e) => { // Hide any completions we are currently showing this._hideCompletions(); let text = o.get_text(); // Ensure we don't get newlines in the command; the history file is // newline-separated. text = text.replace('\n', ' '); this._evaluate(text).catch(logError); return true; }); this._history = new History.HistoryManager({ gsettingsKey: HISTORY_KEY, entry: this._entry.clutter_text, }); this._autoComplete = new AutoComplete(this._entry); this._autoComplete.connect('suggest', (a, e) => { this._showCompletions(e.completions); }); // If a completion is completed unambiguously, the currently-displayed completion // suggestions become irrelevant. this._autoComplete.connect('completion', (a, e) => { if (e.type === 'whole-word') this._hideCompletions(); }); this._resize(); } vfunc_captured_event(event) { if (Main.keyboard.maybeHandleEvent(event)) return Clutter.EVENT_STOP; return Clutter.EVENT_PROPAGATE; } _updateFont() { let fontName = this._interfaceSettings.get_string('monospace-font-name'); let fontDesc = Pango.FontDescription.from_string(fontName); // We ignore everything but size and style; you'd be crazy to set your system-wide // monospace font to be bold/oblique/etc. Could easily be added here. let size = fontDesc.get_size() / 1024.; let unit = fontDesc.get_size_is_absolute() ? 'px' : 'pt'; this.style = ` font-size: ${size}${unit}; font-family: "${fontDesc.get_family()}";`; } setBorderPaintTarget(obj) { if (this._borderPaintTarget != null) this._borderPaintTarget.remove_effect(this._redBorderEffect); this._borderPaintTarget = obj; if (this._borderPaintTarget != null) this._borderPaintTarget.add_effect(this._redBorderEffect); } _pushResult(command, obj) { let index = this._resultsArea.get_n_children() + this._offset; let result = new Result(this, CHEVRON + command, obj, index); this._resultsArea.add(result); if (obj instanceof Clutter.Actor) this.setBorderPaintTarget(obj); if (this._resultsArea.get_n_children() > this._maxItems) { this._resultsArea.get_first_child().destroy(); this._offset++; } this._it = obj; // Scroll to bottom this._notebook.scrollToBottom(0); } _showCompletions(completions) { if (!this._completionActor) { this._completionActor = new St.Label({name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text'}); this._completionActor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; this._completionActor.clutter_text.line_wrap = true; this._evalBox.insert_child_below(this._completionActor, this._entryArea); } this._completionActor.set_text(completions.join(', ')); // Setting the height to -1 allows us to get its actual preferred height rather than // whatever was last set when animating this._completionActor.set_height(-1); let [, naturalHeight] = this._completionActor.get_preferred_height(this._resultsArea.get_width()); // Don't reanimate if we are already visible if (this._completionActor.visible) { this._completionActor.height = naturalHeight; } else { let settings = St.Settings.get(); let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; this._completionActor.show(); this._completionActor.remove_all_transitions(); this._completionActor.ease({ height: naturalHeight, opacity: 255, duration, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } } _hideCompletions() { if (this._completionActor) { let settings = St.Settings.get(); let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; this._completionActor.remove_all_transitions(); this._completionActor.ease({ height: 0, opacity: 0, duration, mode: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => { this._completionActor.hide(); }, }); } } async _evaluate(command) { command = this._history.addItem(command); // trims command if (!command) return; let lines = command.split(';'); lines.push(`return ${lines.pop()}`); let fullCmd = commandHeader + lines.join(';'); let resultObj; try { resultObj = await AsyncFunction(fullCmd)(); } catch (e) { resultObj = ``; } this._pushResult(command, resultObj); this._entry.text = ''; } inspect(x, y) { return global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); } getIt() { return this._it; } getResult(idx) { try { return this._resultsArea.get_child_at_index(idx - this._offset).o; } catch (e) { throw new Error(`Unknown result at index ${idx}`); } } toggle() { if (this._open) this.close(); else this.open(); } _queueResize() { const laters = global.compositor.get_laters(); laters.add(Meta.LaterType.BEFORE_REDRAW, () => { this._resize(); return GLib.SOURCE_REMOVE; }); } _resize() { let primary = Main.layoutManager.primaryMonitor; let myWidth = primary.width * 0.7; let availableHeight = primary.height - Main.layoutManager.keyboardBox.height; let myHeight = Math.min(primary.height * 0.7, availableHeight * 0.9); this.x = primary.x + (primary.width - myWidth) / 2; this._hiddenY = primary.y + Main.layoutManager.panelBox.height - myHeight; this._targetY = this._hiddenY + myHeight; this.y = this._hiddenY; this.width = myWidth; this.height = myHeight; this._objInspector.set_size(Math.floor(myWidth * 0.8), Math.floor(myHeight * 0.8)); this._objInspector.set_position( this.x + Math.floor(myWidth * 0.1), this._targetY + Math.floor(myHeight * 0.1)); } insertObject(obj) { this._pushResult('', obj); } inspectObject(obj, sourceActor) { this._objInspector.open(sourceActor); this._objInspector.selectObject(obj); } // Handle key events which are relevant for all tabs of the LookingGlass vfunc_key_press_event(event) { let symbol = event.get_key_symbol(); if (symbol === Clutter.KEY_Escape) { this.close(); return Clutter.EVENT_STOP; } // Ctrl+PgUp and Ctrl+PgDown switches tabs in the notebook view if (event.get_state() & Clutter.ModifierType.CONTROL_MASK) { if (symbol === Clutter.KEY_Page_Up) this._notebook.prevTab(); else if (symbol === Clutter.KEY_Page_Down) this._notebook.nextTab(); } return super.vfunc_key_press_event(event); } open() { if (this._open) return; let grab = Main.pushModal(this, {actionMode: Shell.ActionMode.LOOKING_GLASS}); if (grab.get_seat_state() !== Clutter.GrabState.ALL) { Main.popModal(grab); return; } this._grab = grab; this._notebook.selectIndex(0); this.show(); this._open = true; this._history.lastItem(); this.remove_all_transitions(); // We inverse compensate for the slow-down so you can change the factor // through LookingGlass without long waits. let duration = LG_ANIMATION_TIME / St.Settings.get().slow_down_factor; this.ease({ y: this._targetY, duration, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); this._windowList.update(); this._entry.grab_key_focus(); } close() { if (!this._open) return; this._objInspector.hide(); this._open = false; this.remove_all_transitions(); this.setBorderPaintTarget(null); let settings = St.Settings.get(); let duration = Math.min( LG_ANIMATION_TIME / settings.slow_down_factor, LG_ANIMATION_TIME); this.ease({ y: this._hiddenY, duration, mode: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => { Main.popModal(this._grab); this._grab = null; this.hide(); }, }); } get isOpen() { return this._open; } });