const St = imports.gi.St;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Shell = imports.gi.Shell;
const Clutter = imports.gi.Clutter;
const Main = imports.ui.main;
const PopupMenu = imports.ui.popupMenu;
const BoxPointer = imports.ui.boxpointer;
const PanelMenu = imports.ui.panelMenu;
const Mainloop = imports.mainloop;
const GObject = imports.gi.GObject
const ME = imports.misc.extensionUtils.getCurrentExtension();
const Gettext = imports.gettext;
Gettext.bindtextdomain(ME.metadata['gettext-domain'], ME.path + '/locale');
const SIG_MANAGER = ME.imports.lib.signal_manager;
const PANEL_ITEM = ME.imports.lib.panel_item;
const MISC_UTILS = ME.imports.lib.misc_utils;
// To add a section, add the module here, update the 'sections' entry in the
// gschema.xml file, and add a toggle to enable/disable it (update ui and
// prefs.js files).
const SECTIONS = new Map([
['Alarms' , ME.imports.sections.alarms],
['Pomodoro' , ME.imports.sections.pomodoro],
['Stopwatch' , ME.imports.sections.stopwatch],
['Timer' , ME.imports.sections.timer],
['Todo' , ME.imports.sections.todo.MAIN],
]);
const ContextMenu = ME.imports.sections.context_menu;
const PanelPosition = {
LEFT : 0,
CENTER : 1,
RIGHT : 2,
};
// =====================================================================
// @@@ Main extension object
// =====================================================================
var Timepp = GObject.registerClass({
Signals: {
'custom-css-changed': {},
'start-time-tracking-by-id': { param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING] },
'stop-time-tracking-by-id': { param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING] }
}
}, class Timepp extends PanelMenu.Button {
_init () {
// @HACK
// This func only updates the max-height prop but not max-width.
// We unset it and do our own thing.
//
// NOTE: Do this before calling the parent constructor because they use
// bind() on the original func..
this._onOpenStateChanged = () => false;
super._init(0.5, 'Timepp');
// @SPEED @HACK
// - We patch the menu.open function to emit the 'open-state-changed'
// in a timeout.
// - We remove the raise_top() func which causes a lot of lag and seems
// to be useless anyway.
this.menu.open = function () {
if (this.isOpen) return;
this.isOpen = true;
// @HACK
// If an extension puts the panel to the bottom and updates the
// _arrowSide prop, then the func _updateFlip() in boxpointer will
// call _calculateArrowSide() and reset that prop if the menu is
// higher than the monitor.
// This will lead to the menu not being shown because it will be
// rendered below the panel.
//
// I don't understand what the point of _calculateArrowSide() is..
//
// Since we patch the open() func, we have to include this hack here.
let panel_pos = Main.layoutManager.panelBox.anchor_y == 0 ? St.Side.TOP : St.Side.BOTTOM;
this._boxPointer._userArrowSide = panel_pos;
this._boxPointer._arrowSide = panel_pos;
this._boxPointer.setPosition(this.sourceActor, this._arrowAlignment);
// If there is another menu open, we emit 'open-state-changed' the
// normal way to avoid any deadlocks.
if (Main.panel.menuManager.activeMenu) {
this._boxPointer.open(BoxPointer.PopupAnimation.FULL);
this.emit('open-state-changed', true);
} else {
// Put in boxpointer callback to play nicely with animations.
this._boxPointer.open(BoxPointer.PopupAnimation.FULL, () => {
let f = global.stage.get_key_focus();
Mainloop.timeout_add(0, () => {
this.emit('open-state-changed', true);
f.grab_key_focus();
});
});
}
};
this.style_class = '';
this.can_focus = false;
this.reactive = false;
this.menu.actor.add_style_class_name('timepp-menu');
this.panel_item_box = new St.BoxLayout({ style_class: 'timepp-panel-box timepp-custom-css-root'});
this.add_actor(this.panel_item_box);
this.markdown_map = new Map([
['`' , ['', '']],
['``' , ['', '']],
['*' , ['', '']],
['**' , ['', '']],
['***' , ['', '']],
['_' , null],
['__' , ['', '']],
['___' , ['', '']],
['~' , null],
['~~' , ['', '']],
['#' , ['', '']],
['##' , ['', '']],
['###' , ['', '']],
]);
this.custom_css = {
['-timepp-link-color'] : ['blue' , [0, 0, 1, 1]],
['-timepp-context-color'] : ['magenta' , [1, 0, 1, 1]],
['-timepp-due-date-color'] : ['red' , [1, 0, 0, 1]],
['-timepp-project-color'] : ['green' , [0, 1, 0, 1]],
['-timepp-rec-date-color'] : ['tomato' , [1, .38, .28, 1]],
['-timepp-defer-date-color'] : ['violet' , [.93, .51, .93, 1]],
['-timepp-axes-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-y-label-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-x-label-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-rulers-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-proj-vbar-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-task-vbar-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-vbar-bg-color'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-A'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-B'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-C'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-D'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-E'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-color-F'] : ['white' , [1, 1, 1, 1]],
['-timepp-heatmap-selected-color'] : ['white', [1, 1, 1, 1]],
};
{
let GioSSS = Gio.SettingsSchemaSource;
let schema = GioSSS.new_from_directory(
ME.path + '/data/schemas', GioSSS.get_default(), false);
schema = schema.lookup('org.gnome.shell.extensions.timepp', false);
this.settings = new Gio.Settings({ settings_schema: schema });
}
// @key: string (a section name)
// @val: object (an instantiated main section object)
//
// This map only holds sections that are currently enabled.
this.sections = new Map();
// @key: string (a section name)
// @val: object (a PopupSeparatorMenuItem().actor)
this.separators = new Map();
this.sigm = new SIG_MANAGER.SignalManager();
this.panel_item_position = this.settings.get_enum('panel-item-position');
this.custom_stylesheet = null;
this.theme_change_signal_block = false;
// ensure cache dir
{
let dir = Gio.file_new_for_path(
`${GLib.get_home_dir()}/.cache/timepp_gnome_shell_extension`);
if (!dir.query_exists(null))
dir.make_directory_with_parents(null);
}
//
// unicon panel item (shown when single panel item mode is selected)
//
this.unicon_panel_item = new PANEL_ITEM.PanelItem(this.menu);
this.unicon_panel_item.icon.gicon = MISC_UTILS.getIcon('timepp-unicon-symbolic');
this.unicon_panel_item.set_mode('icon');
this.unicon_panel_item.actor.add_style_class_name('unicon-panel-item');
if (! this.settings.get_boolean('unicon-mode')) this.unicon_panel_item.actor.hide();
this.panel_item_box.add_child(this.unicon_panel_item.actor);
//
// popup menu
//
this.box_content = new St.BoxLayout({ style_class: 'timepp-content-box', vertical: true});
this.menu.box.add_child(this.box_content);
this.box_content._delegate = this;
//
// context menu
//
this.context_menu = new ContextMenu.ContextMenu(this);
this.box_content.add_actor(this.context_menu.actor);
this.context_menu.actor.hide();
//
// more init
//
this._sync_sections_with_settings();
this.update_panel_items();
Mainloop.idle_add(() => this._load_stylesheet());
//
// listen
//
this.sigm.connect(St.ThemeContext.get_for_stage(global.stage), 'changed', () => {
if (this.theme_change_signal_block) return;
this._on_theme_changed();
});
this.sigm.connect(this.settings, 'changed::panel-item-position', () => {
let new_val = this.settings.get_enum('panel-item-position');
this._on_panel_position_changed(this.panel_item_position, new_val);
this.panel_item_position = new_val;
});
this.sigm.connect(this.settings, 'changed::sections', () => this._sync_sections_with_settings());
this.sigm.connect(this.settings, 'changed::unicon-mode', () => this.update_panel_items());
this.sigm.connect(this.panel_item_box, 'style-changed', () => this._update_custom_css());
this.sigm.connect(this.menu, 'open-state-changed', (_, state) => this._on_open_state_changed(state));
this.sigm.connect(this.unicon_panel_item, 'left-click', () => this.toggle_menu());
this.sigm.connect(this.unicon_panel_item, 'right-click', () => this.toggle_context_menu());
this.sigm.connect(this.unicon_panel_item.actor, 'enter-event', () => { if (Main.panel.menuManager.activeMenu) this.open_menu(); });
this.sigm.connect(this.unicon_panel_item.actor, 'key-focus-in', () => this.open_menu());
}
_sync_sections_with_settings () {
let sections = this.settings.get_value('sections').deep_unpack();
for (let key in sections) {
if (! sections.hasOwnProperty(key)) continue;
if (sections[key].enabled) {
if (! this.sections.has(key)) {
let module = SECTIONS.get(key);
let section = new module.SectionMain(key, this, this.settings);
this.sections.set(key, section);
section.actor.hide();
this.box_content.add_child(section.actor);
this.panel_item_box.add_child(section.panel_item.actor);
let sep = new PopupMenu.PopupSeparatorMenuItem();
sep.actor.add_style_class_name('timepp-separator');
this.box_content.add_child(sep.actor);
this.separators.set(key, sep.actor);
}
} else if (this.sections.has(key)) {
let s = this.sections.get(key);
// The current sourceActor could be the panel_item of the section
// we are about to disable which destroys panel_item.
if (s.panel_item.actor === this.menu.sourceActor)
this._update_menu_arrow(this.actor);
s.disable_section();
this.sections.delete(key);
this.separators.get(key).destroy();
this.separators.delete(key);
}
}
this.update_panel_items();
}
toggle_menu (section_name) {
if (this.menu.isOpen) this.menu.close(false);
else this.open_menu(section_name);
}
// @section: obj (a section's main object)
//
// - If @section is null, then that is assumed to mean that the unicon icon
// has been clicked/activated (i.e., we show all joined menus.)
//
// - If @section is provided, then the menu will open to show that section.
// - If @section is a separate menu, we show it and hide all other menus.
//
// - If @section is not a sep menu, we show all joined sections that
// are enabled.
open_menu (section_name) {
let section = this.sections.get(section_name);
if (this.menu.isOpen && section && section.actor.visible) return;
if (this.context_menu.actor.visible) return;
this.unicon_panel_item.actor.remove_style_pseudo_class('checked');
this.unicon_panel_item.actor.remove_style_pseudo_class('focus');
this.unicon_panel_item.actor.can_focus = true;
// Track sections whose state has changed and call their
// on_section_open_state_changed method after the menu has been shown.
let shown_sections = [];
let hidden_sections = [];
if (!section || !section.separate_menu) {
if (this.unicon_panel_item.actor.visible) {
this._update_menu_arrow(this.unicon_panel_item.actor);
this.unicon_panel_item.actor.add_style_pseudo_class('checked');
this.unicon_panel_item.actor.can_focus = false;
} else {
this._update_menu_arrow(section.panel_item.actor);
}
for (let [, section] of this.sections) {
if (section.separate_menu) {
if (section.actor.visible) {
hidden_sections.push(section);
section.actor.hide();
}
} else if (! section.actor.visible) {
shown_sections.push(section);
section.actor.visible = true;
}
}
} else if (section.separate_menu) {
this._update_menu_arrow(section.panel_item.actor);
if (! section.actor.visible) {
shown_sections.push(section);
section.actor.visible = true;
}
for (let [, section] of this.sections) {
if (section_name === section.section_name ||
!section.actor.visible) continue;
hidden_sections.push(section);
section.actor.visible = false;
}
}
this._update_menu_min_size();
this._update_separators();
this.menu.open();
for (let s of shown_sections) s.on_section_open_state_changed(true);
for (let s of hidden_sections) s.on_section_open_state_changed(false);
}
toggle_context_menu (section_name) {
if (this.menu.isOpen) {
this.menu.close(false);
return;
}
let section = this.sections.get(section_name);
if (section) this._update_menu_arrow(section.panel_item.actor);
else this._update_menu_arrow(this.unicon_panel_item.actor);
this.context_menu.actor.visible = true;
this.unicon_panel_item.actor.add_style_pseudo_class('checked');
this.unicon_panel_item.actor.can_focus = false;
for (let [, section] of this.sections) {
if (section.panel_item.actor.visible) {
section.panel_item.actor.add_style_pseudo_class('checked');
section.panel_item.actor.can_focus = false;
}
}
this._update_menu_min_size();
this._update_separators();
this.menu.open();
}
// PanelMenu only updates the min-height, we also need to update min-width.
_update_menu_min_size () {
let work_area = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.findIndexForActor(this.menu.actor));
let monitor = Main.layoutManager.findMonitorForActor(this.menu.actor);
let scale_factor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
// @HACK
// Some extensions enable autohiding of the panel and as a result the
// height of the panel is not taken into account when computing the
// work area. This is just a simple work around.
let tweak = 16;
if (monitor.height === work_area.height)
tweak = Main.layoutManager.panelBox.height + tweak;
let max_h = Math.floor((work_area.height - tweak) / scale_factor);
let max_w = Math.floor((work_area.width - 16) / scale_factor);
this.menu_max_w = max_w;
this.menu_max_h = max_h;
this.menu.actor.style = `max-height: ${max_h}px; max-width: ${max_w}px;`;
}
_update_menu_arrow (source_actor) {
this.menu._boxPointer.setPosition(source_actor, this.menu._arrowAlignment);
this.menu.sourceActor = source_actor;
}
_update_separators () {
let last_visible;
for (let [k, sep] of this.separators) {
if (this.sections.get(k).actor.visible) {
last_visible = sep;
sep.show();
} else {
sep.hide();
}
}
if (last_visible) last_visible.hide();
}
_update_custom_css () {
let update_needed = false;
let theme_node = this.panel_item_box.get_theme_node();
for (let prop in this.custom_css) {
if (! this.custom_css.hasOwnProperty(prop)) continue;
let [success, col] = theme_node.lookup_color(prop, false);
let hex = col.to_string();
if (success && this.custom_css[prop][0] !== hex) {
update_needed = true;
this.custom_css[prop] = [hex, [
col.red / 255,
col.green / 255,
col.blue / 255,
col.alpha / 255,
]];
}
}
if (update_needed) this.emit('custom-css-changed');
}
update_panel_items () {
if (this.settings.get_boolean('unicon-mode')) {
let show_unicon = false;
for (let [, section] of this.sections) {
if (section.separate_menu) {
section.panel_item.actor.show();
} else {
section.panel_item.actor.hide();
show_unicon = true;
}
}
this.unicon_panel_item.actor.visible = show_unicon;
} else {
this.unicon_panel_item.actor.hide();
for (let [, section] of this.sections) {
section.panel_item.actor.visible = true;
}
}
}
_on_panel_position_changed (old_pos, new_pos) {
let ref = this.container;
switch (old_pos) {
case PanelPosition.LEFT:
Main.panel._leftBox.remove_child(this.container);
break;
case PanelPosition.CENTER:
Main.panel._centerBox.remove_child(this.container);
break;
case PanelPosition.RIGHT:
Main.panel._rightBox.remove_child(this.container);
break;
}
switch (new_pos) {
case PanelPosition.LEFT:
Main.panel._leftBox.add_child(ref);
break;
case PanelPosition.CENTER:
Main.panel._centerBox.add_child(ref);
break;
case PanelPosition.RIGHT:
Main.panel._rightBox.insert_child_at_index(ref, 0);
}
}
_on_open_state_changed (state) {
if (state) return Clutter.EVENT_PROPAGATE;
this.context_menu.actor.hide();
this.unicon_panel_item.actor.remove_style_pseudo_class('checked');
this.unicon_panel_item.actor.remove_style_pseudo_class('focus');
this.unicon_panel_item.actor.can_focus = true;
for (let [, section] of this.sections) {
section.panel_item.actor.remove_style_pseudo_class('checked');
section.panel_item.actor.remove_style_pseudo_class('focus');
section.panel_item.actor.can_focus = true;
if (section.actor.visible) {
section.on_section_open_state_changed(false);
section.actor.visible = false;
}
}
}
_on_theme_changed () {
if (this.custom_stylesheet) this._unload_stylesheet();
this._load_stylesheet();
}
_load_stylesheet () {
this.theme_change_signal_block = true;
// determine custom stylesheet
{
let stylesheet = Main.getThemeStylesheet();
let path = stylesheet ? stylesheet.get_path() : '';
let theme_dir = path ? GLib.path_get_dirname(path) : '';
if (theme_dir) {
this.custom_stylesheet =
Gio.file_new_for_path(theme_dir + '/timepp.css');
}
if (!this.custom_stylesheet ||
!this.custom_stylesheet.query_exists(null)) {
this.custom_stylesheet =
Gio.File.new_for_path(ME.path + '/stylesheet.css');
}
}
St.ThemeContext.get_for_stage(global.stage).get_theme().load_stylesheet(this.custom_stylesheet);
// reload theme
Main.reloadThemeResource();
Main.loadTheme();
Mainloop.idle_add(() => this.theme_change_signal_block = false);
}
_unload_stylesheet () {
if (! this.custom_stylesheet) return;
St.ThemeContext.get_for_stage(global.stage).get_theme().unload_stylesheet(this.custom_stylesheet);
this.custom_stylesheet = null;
}
is_section_enabled (section_name) {
return this.sections.has(section_name);
}
// Used by sections to communicate with each other.
// This way any section can listen for signals on the main ext object.
emit_to_sections (sig, section_name, data) {
this.emit(sig, section_name, data);
}
// ScrollView always allocates horizontal space for the scrollbar when the
// policy is set to AUTOMATIC. The result is an ugly padding on the right
// when the scrollbar is invisible.
needs_scrollbar () {
let [, nat_h] = this.menu.actor.get_preferred_height(-1);
let max_h = this.menu.actor.get_theme_node().get_max_height();
return max_h >= 0 && nat_h >= max_h;
}
destroy () {
// We need to make sure that this one is set to the default actor or
// else the shell will try to destroy the wrong panel actor.
this._update_menu_arrow(this.actor);
for (let [, section] of this.sections) section.disable_section();
this.sections.clear();
this.separators.clear();
this._unload_stylesheet();
this.sigm.clear();
super.destroy();
}
})
// =====================================================================
// @@@ Init
// =====================================================================
function init () {}
let timepp;
function enable () {
timepp = new Timepp();
let pos;
switch (timepp.settings.get_enum('panel-item-position')) {
case PanelPosition.LEFT:
pos = Main.panel._leftBox.get_n_children();
Main.panel.addToStatusArea('timepp', timepp, pos, 'left');
break;
case PanelPosition.CENTER:
pos = Main.panel._centerBox.get_n_children();
Main.panel.addToStatusArea('timepp', timepp, pos, 'center');
break;
case PanelPosition.RIGHT:
Main.panel.addToStatusArea('timepp', timepp, 0, 'right');
}
}
function disable () {
timepp.destroy();
timepp = null;
}