SystemTray/src/dorkbox/systemTray/ui/gtk/GtkMenuItemCheckbox.java

324 lines
12 KiB
Java

/*
* 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.awt.Color;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import com.sun.jna.Pointer;
import dorkbox.jna.linux.GCallback;
import dorkbox.jna.linux.GObject;
import dorkbox.jna.linux.GtkEventDispatch;
import dorkbox.jna.linux.GtkTheme;
import dorkbox.os.OS;
import dorkbox.systemTray.Checkbox;
import dorkbox.systemTray.SystemTray;
import dorkbox.systemTray.peer.CheckboxPeer;
import dorkbox.systemTray.util.EventDispatch;
import dorkbox.systemTray.util.HeavyCheckMark;
import dorkbox.systemTray.util.SizeAndScaling;
class GtkMenuItemCheckbox extends GtkBaseMenuItem implements CheckboxPeer, GCallback {
private static volatile String checkedFile;
// here, it doesn't matter what size the image is, as long as there is an image, the text in the menu will be shifted correctly
// This is set from _AppIndicatorNativeTray or _GtkStatusIconNativeTray
static String uncheckedFile = null;
// Note: So far, ONLY Ubuntu has managed to fail at rendering (via bad layouts) checkbox menu items.
// If there are OTHER OSes that fail, checks for them should be added here
private static final boolean useFakeCheckMark;
static {
// this class is initialized on the GTK dispatch thread.
if (SystemTray.AUTO_FIX_INCONSISTENCIES &&
_AppIndicatorNativeTray.isLoaded &&
OS.Linux.INSTANCE.isUbuntu()) {
// Ubuntu < 17.10 (so 14.04, 14.10, 15.04, 15.10, 16.04, 16.10, 17.04) SCREW UP checkboxes. Ubuntu 17.10 uses gnome-shell properly and thus works correctly.
int[] version = OS.Linux.INSTANCE.getUbuntuVersion();
useFakeCheckMark = (version[0] < 17 || (version[0] == 17 && version[1] == 4));
} else {
useFakeCheckMark = false;
}
if (SystemTray.DEBUG) {
SystemTray.logger.debug("Using Fake CheckMark: " + useFakeCheckMark);
}
}
private final GtkMenu parent;
// these have to be volatile, because they can be changed from any thread
private volatile ActionListener callback;
private volatile boolean isChecked = false;
private volatile Pointer checkedImage;
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;
private final long handlerId;
/**
* called from inside GTK dispatch thread. ONLY creates the menu item, but DOES NOT attach it!
* this is a FLOATING reference. See: https://developer.gnome.org/gobject/stable/gobject-The-Base-Object-Type.html#floating-ref
*
* Because Ubuntu AppIndicator checkbox's DO NOT align correctly, we use an image_menu_item (instead of a check_menu_item),
* so that the alignment is correct for the menu item (with a check_menu_item, they are shifted left - which looks pretty bad)
*
* For AppIndicators, this is not possible to fix, because we cannot control how the menu's are rendered (this is by design)
* Specifically, since it's implementation was copied from GTK, GtkCheckButton and GtkRadioButton allocate only the minimum size
* necessary for its child. This causes the child alignment to fail. There is no fix we can apply - so we don't use them.
*
* Again, this is ONLY noticed on UBUNTU. For example, ElementaryOS is OK (it is also with a checkbox on the right).
* ElementaryOS shows the checkbox on the right, everyone else is on the left. With eOS, we CANNOT show the spacer image, so we MUST
* show this as a GTK Status Icon (not an AppIndicator), this way the "proper" checkbox is shown.
*/
GtkMenuItemCheckbox(final GtkMenu parent) {
super(useFakeCheckMark ?
Gtk2.gtk_image_menu_item_new_with_mnemonic("") :
Gtk2.gtk_check_menu_item_new_with_mnemonic(""));
this.parent = parent;
handlerId = GObject.g_signal_connect_object(_native, "activate", this, null, 0);
if (useFakeCheckMark) {
if (checkedFile == null) {
Color color = GtkTheme.getTextColor();
if (color == null) {
SystemTray.logger.error("Unable to determine the text color in use by your system. Please create an issue and include your " +
"full OS configuration and desktop environment, including theme details, such as the theme name, color " +
"variant, and custom theme options (if any).");
color = Color.BLACK;
}
if (checkedFile == null) {
Rectangle size = GtkTheme.getPixelTextHeight("X");
int imageHeight = SizeAndScaling.TRAY_MENU_SIZE;
int height = size.height;
if (SystemTray.DEBUG) {
SystemTray.logger.debug("Fake checkmark size: {}px", height);
}
if (_AppIndicatorNativeTray.isLoaded) {
// only app indicators don't need padding, as they automatically center the icon
checkedFile = HeavyCheckMark.get(color, height, height);
} else {
checkedFile = HeavyCheckMark.get(color, height, imageHeight);
}
}
}
setCheckedIconForFakeCheckMarks();
} else {
GObject.g_signal_handler_block(_native, handlerId);
Gtk2.gtk_check_menu_item_set_active(_native, false);
GObject.g_signal_handler_unblock(_native, handlerId);
}
}
// called by native code ONLY
@Override
public
int callback(final Pointer instance, final Pointer data) {
ActionListener callback = this.callback;
if (callback != null) {
GtkEventDispatch.proxyClick(callback);
}
return Gtk2.TRUE;
}
@Override
public
boolean hasImage() {
return true;
}
@Override
public
void setSpacerImage(final boolean everyoneElseHasImages) {
// no op
}
@Override
public
void setEnabled(final Checkbox menuItem) {
GtkEventDispatch.dispatch(()->Gtk2.gtk_widget_set_sensitive(_native, menuItem.getEnabled()));
}
@Override
public
void setText(final Checkbox menuItem) {
final String textWithMnemonic;
if (mnemonicKey != 0) {
String text = menuItem.getText();
// 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 = menuItem.getText();
}
GtkEventDispatch.dispatch(()->{
Gtk2.gtk_menu_item_set_label(_native, textWithMnemonic);
Gtk2.gtk_widget_show_all(_native);
});
}
@SuppressWarnings({"Duplicates"})
@Override
public
void setCallback(final Checkbox menuItem) {
callback = menuItem.getCallback(); // can be set to null
if (callback != null) {
callback = new ActionListener() {
final ActionListener cb = menuItem.getCallback();
@Override
public
void actionPerformed(ActionEvent e) {
// this will run on the EDT, since we are calling it from the EDT. This can ALSO recursively call the callback
menuItem.setChecked(!isChecked);
// we want it to run on our own with our own action event info (so it is consistent across all platforms)
EventDispatch.runLater(()->{
try {
cb.actionPerformed(new ActionEvent(menuItem, ActionEvent.ACTION_PERFORMED, ""));
} catch (Throwable throwable) {
SystemTray.logger.error("Error calling menu checkbox entry {} click event.", menuItem.getText(), throwable);
}
});
}
};
}
}
@Override
public
void setChecked(final Checkbox menuItem) {
final boolean checked = menuItem.getChecked();
// only dispatch if it's actually different
if (checked != this.isChecked) {
this.isChecked = checked;
GtkEventDispatch.dispatch(()->{
if (useFakeCheckMark) {
setCheckedIconForFakeCheckMarks();
} else {
// note: this will trigger "activate", which will then trigger the callback.
// we assume this is consistent across ALL versions and variants of GTK
// https://github.com/GNOME/gtk/blob/master/gtk/gtkcheckmenuitem.c#L317
// this disables the signal handler, then enables it
GObject.g_signal_handler_block(_native, handlerId);
Gtk2.gtk_check_menu_item_set_active(_native, isChecked);
GObject.g_signal_handler_unblock(_native, handlerId);
}
});
}
}
@Override
public
void setTooltip(final Checkbox 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());
});
}
// this is pretty much ONLY for Ubuntu AppIndicators
private
void setCheckedIconForFakeCheckMarks() {
if (checkedImage != null) {
Gtk2.gtk_container_remove(_native, checkedImage); // will automatically get destroyed if no other references to it
checkedImage = null;
}
if (this.isChecked) {
checkedImage = Gtk2.gtk_image_new_from_file(checkedFile);
} else {
checkedImage = Gtk2.gtk_image_new_from_file(uncheckedFile);
}
Gtk2.gtk_image_menu_item_set_image(_native, checkedImage);
// 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);
}
@Override
public
void setShortcut(final Checkbox checkbox) {
char shortcut = checkbox.getShortcut();
if (shortcut != 0) {
this.mnemonicKey = Character.toLowerCase(shortcut);
} else {
this.mnemonicKey = 0;
}
setText(checkbox);
}
@SuppressWarnings("Duplicates")
@Override
public
void remove() {
GtkEventDispatch.dispatch(()->{
GtkMenuItemCheckbox.super.remove();
callback = null;
Gtk2.gtk_container_remove(parent._nativeMenu, _native); // will automatically get destroyed if no other references to it
if (image != null) {
Gtk2.gtk_container_remove(_native, image); // will automatically get destroyed if no other references to it
image = null;
}
parent.remove(GtkMenuItemCheckbox.this);
});
}
}