/* * Copyright 2021 dorkbox, llc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package dorkbox.systemTray.ui.gtk; import static dorkbox.jna.linux.Gtk.Gtk2; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import com.sun.jna.Pointer; import dorkbox.jna.linux.GtkEventDispatch; import dorkbox.systemTray.Checkbox; import dorkbox.systemTray.Entry; import dorkbox.systemTray.Menu; import dorkbox.systemTray.MenuItem; import dorkbox.systemTray.Separator; import dorkbox.systemTray.Status; import dorkbox.systemTray.peer.MenuPeer; class GtkMenu extends GtkBaseMenuItem implements MenuPeer { // this is a list (that mirrors the actual list) BECAUSE we have to create/delete the entire menu in GTK every time something is changed private final List menuEntries = new ArrayList<>(); private final GtkMenu parent; // null when we are the main menu attached to the tray icon volatile Pointer _nativeMenu; // must ONLY be created at the end of delete! private volatile Pointer image; // The mnemonic will ONLY show-up once a menu entry is selected. IT WILL NOT show up before then! // AppIndicators will only show if you use the keyboard to navigate // GtkStatusIconTray will show on mouse+keyboard movement private volatile char mnemonicKey = 0; // have to make sure no other methods can call obliterate, delete, or create menu once it's already started private final AtomicBoolean obliterateInProgress = new AtomicBoolean(false); // called by the system tray constructors // This is NOT a copy constructor! @SuppressWarnings("IncompleteCopyConstructor") GtkMenu() { super(null); this.parent = null; } // This is NOT a copy constructor! @SuppressWarnings("IncompleteCopyConstructor") private GtkMenu(final GtkMenu parent) { super(Gtk2.gtk_image_menu_item_new_with_mnemonic("")); // is what is added to the parent menu (so images work) this.parent = parent; } GtkMenu getParent() { return parent; } /** * Called inside the gdk_threads block * * ALWAYS CALLED ON THE EDT */ protected void onMenuAdded(final Pointer menu) { // only needed for AppIndicator } /** * Deletes the menu, and unreferences everything in it. ALSO recreates ONLY the menu object. * * some GTK libraries DO NOT let us add items AFTER the menu has been attached to the indicator. * To work around this issue, we destroy then recreate the menu every time something is changed. * * ALWAYS CALLED ON EDT */ @SuppressWarnings("ForLoopReplaceableByForEach") private void deleteMenu(boolean recursiveDeleteParentMenu) { if (obliterateInProgress.get()) { return; } if (_nativeMenu != null) { // have to remove all other menu entries for (int i = 0, menuEntriesSize = menuEntries.size(); i < menuEntriesSize; i++) { final GtkBaseMenuItem menuEntry__ = menuEntries.get(i); menuEntry__.onDeleteMenu(_nativeMenu); } Gtk2.gtk_widget_destroy(_nativeMenu); } if (parent != null && recursiveDeleteParentMenu) { parent.deleteMenu(true); } } /** * some GTK libraries DO NOT let us add items AFTER the menu has been attached to the indicator. * * To work around this issue, we destroy then recreate the menu every time something is changed. * * ALWAYS CALLED ON THE EDT */ @SuppressWarnings("ForLoopReplaceableByForEach") private void createMenu(boolean recursiveCreateParentMenu) { if (obliterateInProgress.get()) { return; } // makes a new one _nativeMenu = Gtk2.gtk_menu_new(); // binds sub-menu to entry (if it exists! it does not for the root menu) if (parent != null) { Gtk2.gtk_menu_item_set_submenu(_native, _nativeMenu); } if (parent != null && recursiveCreateParentMenu) { parent.createMenu(true); } // now add back other menu entries boolean hasImages = false; for (int i = 0, menuEntriesSize = menuEntries.size(); i < menuEntriesSize; i++) { final GtkBaseMenuItem menuEntry__ = menuEntries.get(i); hasImages |= menuEntry__.hasImage(); } for (int i = 0, menuEntriesSize = menuEntries.size(); i < menuEntriesSize; i++) { // the menu entry looks FUNKY when there are a mis-match of entries WITH and WITHOUT images final GtkBaseMenuItem menuEntry__ = menuEntries.get(i); menuEntry__.onCreateMenu(_nativeMenu, hasImages); if (menuEntry__ instanceof GtkMenu) { GtkMenu subMenu = (GtkMenu) menuEntry__; if (subMenu.getParent() != GtkMenu.this) { // we don't want to "createMenu" on our sub-menu that is assigned to us directly, as they are already doing it subMenu.createMenu(recursiveCreateParentMenu); } } } onMenuAdded(_nativeMenu); } /** * Completely obliterates the menu, no possible way to reconstruct it. * * ALWAYS CALLED ON THE EDT */ @SuppressWarnings("ForLoopReplaceableByForEach") private void obliterateMenu() { if (_nativeMenu != null && !obliterateInProgress.get()) { obliterateInProgress.set(true); // have to remove all other menu entries // a copy is made because sub-menus remove themselves from parents when .remove() is called. If we don't // do this, errors will be had because indices don't line up anymore. ArrayList menuEntriesCopy = new ArrayList<>(menuEntries); menuEntries.clear(); for (int i = 0, menuEntriesSize = menuEntriesCopy.size(); i < menuEntriesSize; i++) { final GtkBaseMenuItem menuEntry__ = menuEntriesCopy.get(i); menuEntry__.remove(); } menuEntriesCopy.clear(); Gtk2.gtk_widget_destroy(_nativeMenu); _nativeMenu = null; obliterateInProgress.set(false); } } @Override public void add(final Menu parentMenu, final Entry entry, final int index) { // must always be called on the GTK dispatch. This must be dispatchAndWait() so it will properly executed immediately GtkEventDispatch.dispatchAndWait(()->{ // some GTK libraries DO NOT let us add items AFTER the menu has been attached to the indicator. // To work around this issue, we destroy then recreate the menu every time something is changed. // when adding/removing menus DURING the `add` operation for a menu, we DO NOT want to recursively add/remove menus! deleteMenu(false); GtkBaseMenuItem item = null; if (entry instanceof Menu) { // some implementations of appindicator, do NOT like having a menu added, which has no menu items yet. // see: https://bugs.launchpad.net/glipper/+bug/1203888 item = new GtkMenu(GtkMenu.this); menuEntries.add(index, item); } else if (entry instanceof Separator) { item = new GtkMenuItemSeparator(GtkMenu.this); menuEntries.add(index, item); } else if (entry instanceof Checkbox) { item = new GtkMenuItemCheckbox(GtkMenu.this); menuEntries.add(index, item); } else if (entry instanceof Status) { item = new GtkMenuItemStatus(GtkMenu.this); menuEntries.add(index, item); } else if (entry instanceof MenuItem) { item = new GtkMenuItem(GtkMenu.this); menuEntries.add(index, item); } // we must create the menu BEFORE binding the menu, otherwise the menus' children's GTK element can be added before // their parent GTK elements are added (and the menu won't show up) if (entry instanceof Menu) { ((Menu) entry).bind((GtkMenu) item, parentMenu, parentMenu.getImageResizeUtil()); } else if (entry instanceof Separator) { ((Separator)entry).bind((GtkMenuItemSeparator) item, parentMenu, parentMenu.getImageResizeUtil()); } else if (entry instanceof Checkbox) { ((Checkbox) entry).bind((GtkMenuItemCheckbox) item, parentMenu, parentMenu.getImageResizeUtil()); } else if (entry instanceof Status) { ((Status) entry).bind((GtkMenuItemStatus) item, parentMenu, parentMenu.getImageResizeUtil()); } else if (entry instanceof MenuItem) { ((MenuItem) entry).bind((GtkMenuItem) item, parentMenu, parentMenu.getImageResizeUtil()); } // when adding/removing menus DURING the `add` operation for a menu, we DO NOT want to recursively add/remove menus! createMenu(false); // only call show on the ROOT menu! if (parent == null) { Gtk2.gtk_widget_show_all(_nativeMenu); } }); } // NOTE: XFCE used to use appindicator3, which DOES NOT support images in the menu. This change was reverted. // see: https://ask.fedoraproject.org/en/question/23116/how-to-fix-missing-icons-in-program-menus-and-context-menus/ // see: https://git.gnome.org/browse/gtk+/commit/?id=627a03683f5f41efbfc86cc0f10e1b7c11e9bb25 // is overridden in tray impl @SuppressWarnings("Duplicates") @Override public void setImage(final MenuItem menuItem) { // is overridden by system tray setLegitImage(menuItem.getImage() != null); GtkEventDispatch.dispatch(()->{ if (image != null) { Gtk2.gtk_container_remove(_native, image); // will automatically get destroyed if no other references to it image = null; } if (menuItem.getImage() != null) { image = Gtk2.gtk_image_new_from_file(menuItem.getImage().getAbsolutePath()); Gtk2.gtk_image_menu_item_set_image(_native, image); // must always re-set always-show after setting the image Gtk2.gtk_image_menu_item_set_always_show_image(_native, true); } Gtk2.gtk_widget_show_all(_native); }); } // is overridden in tray impl @Override public void setEnabled(final MenuItem menuItem) { // is overridden by system tray GtkEventDispatch.dispatch(()->Gtk2.gtk_widget_set_sensitive(_native, menuItem.getEnabled())); } // is overridden in tray impl @SuppressWarnings("Duplicates") @Override public void setText(final MenuItem menuItem) { // is overridden by system tray final String textWithMnemonic; if (mnemonicKey != 0) { String text = menuItem.getText(); if (text != null) { // they are CASE INSENSITIVE! int i = text.toLowerCase() .indexOf(mnemonicKey); if (i >= 0) { textWithMnemonic = text.substring(0, i) + "_" + text.substring(i); } else { textWithMnemonic = menuItem.getText(); } } else { textWithMnemonic = null; } } else { textWithMnemonic = menuItem.getText(); } GtkEventDispatch.dispatch(()->{ Gtk2.gtk_menu_item_set_label(_native, textWithMnemonic); Gtk2.gtk_widget_show_all(_native); }); } // is overridden in tray impl @Override public void setCallback(final MenuItem menuItem) { // can't have a callback for menus! } // is overridden in tray impl @Override public void setShortcut(final MenuItem menuItem) { char shortcut = menuItem.getShortcut(); if (shortcut != 0) { this.mnemonicKey = Character.toLowerCase(shortcut); } else { this.mnemonicKey = 0; } setText(menuItem); } @Override public void setTooltip(final MenuItem menuItem) { GtkEventDispatch.dispatch(()->{ // NOTE: this will not work for AppIndicator tray types! // null will remove the tooltip Gtk2.gtk_widget_set_tooltip_text(_native, menuItem.getTooltip()); }); } /** * called when a child removes itself from the parent menu. Does not work for sub-menus * * ALWAYS CALLED ON THE EDT */ public void remove(final GtkBaseMenuItem item) { menuEntries.remove(item); // have to rebuild the menu now... deleteMenu(true); // must be on EDT createMenu(true); // must be on EDT } // a child will always remove itself from the parent. @Override public void remove() { GtkEventDispatch.dispatch(()->{ GtkMenu parent = getParent(); if (parent != null) { // have to remove from the parent.menuEntries first parent.menuEntries.remove(GtkMenu.this); } // delete all of the children of this submenu (must happen before the menuEntry is removed) obliterateMenu(); // must be on EDT if (parent != null) { // remove the gtk entry item from our menu NATIVE components Gtk2.gtk_menu_item_set_submenu(_native, null); // have to rebuild the menu now... parent.deleteMenu(true); // must be on EDT parent.createMenu(true); // must be on EDT } }); } }