Blob Blame History Raw
/* vim:set et sts=4 sw=4:
 *
 * ibus - The Input Bus
 *
 * Copyright(c) 2013-2016 Red Hat, Inc.
 * Copyright(c) 2013-2015 Peng Huang <shawn.p.huang@gmail.com>
 * Copyright(c) 2013-2017 Takao Fujiwara <takao.fujiwara1@gmail.com>
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
 * USA
 */

enum PanelShow {
    DO_NOT_SHOW,
    AUTO_HIDE,
    ALWAYS
}

public class PropertyPanel : Gtk.Box {
    private unowned Gdk.Window m_root_window;
    private unowned X.Display m_xdisplay;
    private Gtk.Window m_toplevel;
    private IBus.PropList m_props;
    private IPropToolItem[] m_items;
    private Gdk.Rectangle m_cursor_location = Gdk.Rectangle(){
            x = -1, y = -1, width = 0, height = 0 };
    private int m_show = PanelShow.DO_NOT_SHOW;
    private uint m_auto_hide_timeout = 10000;
    private uint m_auto_hide_timeout_id = 0;
    private bool m_follow_input_cursor_when_always_shown = false;
    // The timeout indicates milliseconds. 1000 msec == 1 sec
    private const uint MONITOR_NET_WORKAREA_TIMEOUT = 300000;
    private uint m_remove_filter_id;

    public PropertyPanel() {
        /* Chain up base class constructor */
        GLib.Object(orientation: Gtk.Orientation.HORIZONTAL,
                    spacing: 0);

        set_visible(true);

        m_root_window = Gdk.get_default_root_window();
        unowned Gdk.Display display = m_root_window.get_display();
#if VALA_0_24
        m_xdisplay = (display as Gdk.X11.Display).get_xdisplay();
#else
        m_xdisplay = Gdk.X11Display.get_xdisplay(display);
#endif

        m_toplevel = new Gtk.Window(Gtk.WindowType.POPUP);
        m_toplevel.add_events(Gdk.EventMask.BUTTON_PRESS_MASK);

        Handle handle = new Handle();
        handle.set_visible(true);
        pack_start(handle, false, false, 0);

        m_toplevel.add(this);

        m_toplevel.size_allocate.connect((w, a) => {
            if (!m_follow_input_cursor_when_always_shown &&
                m_show == PanelShow.ALWAYS && m_items.length > 0 &&
                m_cursor_location.x == -1 && m_cursor_location.y == -1) {
                set_default_location();
                m_cursor_location.x = 0;
                m_cursor_location.y = 0;
            }
        });

        // PropertyPanel runs before KDE5 panel runs and
        // monitor the desktop size.
        monitor_net_workarea_atom();
    }

    public void set_properties(IBus.PropList props) {
        debug("set_properties()\n");

        // When click PropMenuToolButton, the focus is changed and
        // set_properties() is called here while the menu button is active.
        // Ignore that case here not to remove items.
        bool has_active = false;
        foreach (var item in m_items) {
            Type type = item.get_type();
            if (type == typeof(PropMenuToolButton) ||
                type == typeof(PropToggleToolButton)) {
                if ((item as Gtk.ToggleToolButton).get_active()) {
                    has_active = true;
                    break;
                }
            }
        }
        if (has_active)
            return;

        foreach (var item in m_items)
            remove((item as Gtk.Widget));
        m_items = {};

        m_props = props;

        create_menu_items();

        /* show_with_auto_hide_timer() needs to call here because
         * if the event order is, focus_in(), set_cursor_location() and
         * set_properties(), m_items.length can be 0 when
         * set_cursor_location() is called and Property Panel is not shown.
         */
        show_with_auto_hide_timer();
    }

    public void update_property(IBus.Property prop) {
        GLib.assert(prop != null);

        debug("update_property(prop.key = %s)\n", prop.get_key());

        if (m_props != null)
            m_props.update_property(prop);

        /* Need to update GUI since panel buttons are not redrawn. */
        foreach (var item in m_items)
            item.update_property(prop);

        show_with_auto_hide_timer();
    }

    public void set_cursor_location(int x, int y, int width, int height) {
        if (!m_follow_input_cursor_when_always_shown &&
            m_show == PanelShow.ALWAYS)
            return;

        /* FIXME: set_cursor_location() has a different behavior
         * in embedded preedit by applications.
         * GtkTextView applications, e.g. gedit, always call
         * set_cursor_location() with and without preedit
         * but VTE applications, e.g. gnome-terminal, and xterm
         * do not call set_cursor_location() with preedit.
         * firefox and thunderbird do not call set_cursor_location()
         * without preedit.
         * This may treat GtkIMContext and XIM with different ways.
         * Maybe get_preedit_string() class method.
         */

        /* FIXME: When the cursor is at the bottom of the screen,
         * gedit returns the right cursor position but terminal applications
         * such as gnome-terminal, xfce4-terminal and etc, the position is
         * not accurate and the cursor and panel could be overlapped slightly.
         * Maybe it's a bug in vte.
         */
        Gdk.Rectangle location = Gdk.Rectangle(){
            x = x, y = y, width = width, height = height };

        if (m_cursor_location == location)
            return;

        debug("set_cursor_location(x = %d, y = %d, width = %d, height = %d)\n",
              x, y, width, height);

        /* Hide the panel in AUTO_HIDE mode when the cursor position is
         * chagned on the same input context by typing keyboard or
         * clicking mouse. (But not focus change or property change)
         */
        if (m_show == PanelShow.AUTO_HIDE)
            if (m_cursor_location.x != -1 || m_cursor_location.y != -1) {
                m_cursor_location = location;
                hide_if_necessary();
                adjust_window_position();
                return;
            }

        m_cursor_location = location;
        adjust_window_position();
        show_with_auto_hide_timer();
    }

    public void set_preedit_text(IBus.Text? text, uint cursor) {
        if (text == null && cursor == 0)
            return;

        debug("set_preedit_text(text, cursor = %u)\n", cursor);

        /* Hide the panel in AUTO_HIDE mode when embed-preedit-text value
         * is disabled and the preedit is changed on the same input context.
         */
        hide_if_necessary();
    }

    public void set_auxiliary_text(IBus.Text? text) {
        if (text == null)
            return;

        debug("set_auxiliary_text(text)\n");

        hide_if_necessary();
    }

    public void set_lookup_table(IBus.LookupTable? table) {
        if (table == null)
            return;

        debug("set_lookup_table(table)\n");

        hide_if_necessary();
    }

    public new void show() {
        /* m_items.length is not checked here because set_properties()
         * is not called yet when set_show() is called. */
        if (m_show == PanelShow.DO_NOT_SHOW) {
            m_toplevel.hide();
            return;
        }
        else if (m_show == PanelShow.ALWAYS) {
            m_toplevel.show_all();
            return;
        }

        /* Do not change the state here if m_show == AUTO_HIDE. */
    }

    public new void hide() {
        m_toplevel.hide();
    }

    public void focus_in() {
        debug("focus_in()\n");

        /* Reset m_auto_hide_timeout_id in previous focus-in */
        hide_if_necessary();

        /* Invalidate m_cursor_location before set_cursor_location()
         * is called because the position can be same even if the input
         * focus is changed.
         * E.g. Two tabs on gnome-terminal can keep the cursor position.
         */
        if (m_follow_input_cursor_when_always_shown ||
            m_show != PanelShow.ALWAYS)
            m_cursor_location = { -1, -1, 0, 0 };

       /* set_cursor_location() will be called later. */
    }

    public void set_show(int _show) {
        m_show = _show;
        show();
    }

    public void set_auto_hide_timeout(uint timeout) {
        m_auto_hide_timeout = timeout;
    }

    public void set_follow_input_cursor_when_always_shown(bool is_follow) {
        m_follow_input_cursor_when_always_shown = is_follow;
    }

    private void create_menu_items() {
        int i = 0;
        while (true) {
            IBus.Property prop = m_props.get(i);
            if (prop == null)
                break;

            i++;
            IPropToolItem item = null;
            switch(prop.get_prop_type()) {
                case IBus.PropType.NORMAL:
                    item = new PropToolButton(prop);
                    break;
                case IBus.PropType.TOGGLE:
                    item = new PropToggleToolButton(prop);
                    break;
                case IBus.PropType.MENU:
                    item = new PropMenuToolButton(prop);
                    break;
                case IBus.PropType.SEPARATOR:
                    item = new PropSeparatorToolItem(prop);
                    break;
                default:
                    warning("unknown property type %d",
                            (int) prop.get_prop_type());
                    break;
            }
            if (item != null) {
                pack_start(item as Gtk.Widget, false, false, 0);
                m_items += item;
                item.property_activate.connect((w, k, s) =>
                                               property_activate(k, s));
            }
        }
    }

    private void move(int x, int y) {
        m_toplevel.move(x, y);
    }

    private void adjust_window_position() {
        Gdk.Point cursor_right_bottom = {
                m_cursor_location.x + m_cursor_location.width,
                m_cursor_location.y + m_cursor_location.height
        };

        Gtk.Allocation allocation;
        m_toplevel.get_allocation(out allocation);
        Gdk.Point window_right_bottom = {
            cursor_right_bottom.x + allocation.width,
            cursor_right_bottom.y + allocation.height
        };

        int root_width = m_root_window.get_width();
        int root_height = m_root_window.get_height();

        int x, y;
        if (window_right_bottom.x > root_width)
            x = root_width - allocation.width;
        else
            x = cursor_right_bottom.x;

        if (window_right_bottom.y > root_height)
            y = m_cursor_location.y - allocation.height;
        else
            y = cursor_right_bottom.y;

        move(x, y);
    }

    private bool is_bottom_panel() {
        string desktop = Environment.get_variable("XDG_CURRENT_DESKTOP");
        // LXDE has not implemented DesktopNames yet.
        if (desktop == null)
            desktop = Environment.get_variable("XDG_SESSION_DESKTOP");
        switch (desktop) {
            case "KDE":         return true;
            case "LXDE":        return true;
            default:            return false;
        }
    }

    private void set_default_location() {
        Gtk.Allocation allocation;
        m_toplevel.get_allocation(out allocation);

        Gdk.Rectangle monitor_area;
#if VALA_0_34
        // gdk_screen_get_monitor_workarea() no longer return the correct
        // area from "_NET_WORKAREA" atom in GTK 3.22
        Gdk.Monitor monitor = Gdk.Display.get_default().get_monitor(0);
        monitor_area = monitor.get_workarea();
#else
        Gdk.Screen screen = Gdk.Screen.get_default();
        monitor_area = screen.get_monitor_workarea(0);
#endif
        int monitor_right = monitor_area.x + monitor_area.width;
        int monitor_bottom = monitor_area.y + monitor_area.height;
        int x, y;
        if (is_bottom_panel()) {
            /* Translators: If your locale is RTL, the msgstr is "default:RTL".
             * Otherwise the msgstr is "default:LTR". */
            if (_("default:LTR") != "default:RTL") {
                x = monitor_right - allocation.width;
                y = monitor_bottom - allocation.height;
            } else {
                x = monitor_area.x;
                y = monitor_bottom - allocation.height;
            }
        } else {
            if (_("default:LTR") != "default:RTL") {
                x = monitor_right - allocation.width;
                y = monitor_area.y;
            } else {
                x = monitor_area.x;
                y = monitor_area.y;
            }
        }

        move(x, y);
    }

    private Gdk.FilterReturn root_window_filter(Gdk.XEvent gdkxevent,
                                                Gdk.Event  event) {
        X.Event *xevent = (X.Event*) gdkxevent;
        if (xevent.type == X.EventType.PropertyNotify) {
            string aname = m_xdisplay.get_atom_name(xevent.xproperty.atom);
            if (aname == "_NET_WORKAREA" && xevent.xproperty.state == 0) {
                set_default_location();
                m_root_window.remove_filter(root_window_filter);
                if (m_remove_filter_id > 0) {
                    GLib.Source.remove(m_remove_filter_id);
                    m_remove_filter_id = 0;
                }
                return Gdk.FilterReturn.CONTINUE;
            }
        }
        return Gdk.FilterReturn.CONTINUE;
    }

    private void monitor_net_workarea_atom() {
        Gdk.EventMask events = m_root_window.get_events();
        if ((events & Gdk.EventMask.PROPERTY_CHANGE_MASK) == 0)
            m_root_window.set_events (events |
                                      Gdk.EventMask.PROPERTY_CHANGE_MASK);

        m_root_window.add_filter(root_window_filter);

        m_remove_filter_id = GLib.Timeout.add(MONITOR_NET_WORKAREA_TIMEOUT,
                                              () => {
            m_remove_filter_id = 0;
            m_root_window.remove_filter(root_window_filter);
            return false;
        },
        GLib.Priority.DEFAULT_IDLE);
    }

    private void show_with_auto_hide_timer() {
        /* Do not call gtk_window_resize() in
         * GtkWidgetClass->get_preferred_width()
         * because the following warning is shown in GTK 3.20:
         * "Allocating size to GtkWindow %x without calling
         * gtk_widget_get_preferred_width/height(). How does the code
         * know the size to allocate?"
         * in gtk_widget_size_allocate_with_baseline() */
        m_toplevel.resize(1, 1);

        if (m_items.length == 0) {
            /* Do not blink the panel with focus-in in case the panel
             * is always shown. */
            if (m_follow_input_cursor_when_always_shown ||
                m_show != PanelShow.ALWAYS)
                m_toplevel.hide();

            return;
        }

        if (m_show != PanelShow.AUTO_HIDE) {
            show();
            return;
        }

        /* If all windows are closed, desktop background is focused and
         * focus_in() and set_properties() are called but
         * set_cursor_location() is not called.
         * Then we should not show Property panel when m_cursor_location
         * is (-1, -1) because of no windows.
         */
        if (m_cursor_location.x == -1 && m_cursor_location.y == -1)
            return;

        if (m_auto_hide_timeout_id != 0)
            GLib.Source.remove(m_auto_hide_timeout_id);

        m_toplevel.show_all();

        /* Change the priority because IME typing sometimes freezes. */
        m_auto_hide_timeout_id = GLib.Timeout.add(m_auto_hide_timeout, () => {
            m_toplevel.hide();
            m_auto_hide_timeout_id = 0;
            return false;
        },
        GLib.Priority.DEFAULT_IDLE);
    }

    private void hide_if_necessary() {
        if (m_show == PanelShow.AUTO_HIDE && m_auto_hide_timeout_id != 0) {
            GLib.Source.remove(m_auto_hide_timeout_id);
            m_auto_hide_timeout_id = 0;
            m_toplevel.hide();
        }
    }

    public signal void property_activate(string key, int state);
}

public interface IPropToolItem : GLib.Object {
    public abstract void update_property(IBus.Property prop);
    public signal void property_activate(string key, int state);
}

public class PropMenu : Gtk.Menu, IPropToolItem {
    private Gtk.Widget m_parent_button;
    private IPropItem[] m_items;

    public PropMenu(IBus.Property prop) {
        /* Chain up base class constructor */
        GLib.Object();

        set_take_focus(false);
        create_items(prop.get_sub_props());
        show_all();
        set_sensitive(prop.get_sensitive());
    }

    public void update_property(IBus.Property prop) {
        foreach (var item in m_items)
            item.update_property(prop);
    }

    public new void popup(uint       button,
                          uint32     activate_time,
                          Gtk.Widget widget) {
#if VALA_0_34
        base.popup_at_widget(widget,
                             Gdk.Gravity.SOUTH_WEST,
                             Gdk.Gravity.NORTH_WEST,
                             null);
#else
        m_parent_button = widget;
        base.popup(null, null, menu_position, button, activate_time);
#endif
    }

    public override void destroy() {
        m_parent_button = null;
        foreach (var item in m_items)
            remove((item as Gtk.Widget));
        m_items = {};
        base.destroy();
    }

    private void create_items(IBus.PropList props) {
        int i = 0;
        PropRadioMenuItem last_radio = null;

        while (true) {
            IBus.Property prop = props.get(i);
            if (prop == null)
                break;

            i++;
            IPropItem item = null;
            switch(prop.get_prop_type()) {
                case IBus.PropType.NORMAL:
                    item = new PropImageMenuItem(prop);
                    break;
                case IBus.PropType.TOGGLE:
                    item = new PropCheckMenuItem(prop);
                    break;
                case IBus.PropType.RADIO:
                    last_radio = new PropRadioMenuItem(prop, last_radio);
                    item = last_radio;
                    break;
                case IBus.PropType.MENU:
                    {
                        var menuitem = new PropImageMenuItem(prop);
                        menuitem.set_submenu(new PropMenu(prop));
                        item = menuitem;
                    }
                    break;
                case IBus.PropType.SEPARATOR:
                    item = new PropSeparatorMenuItem(prop);
                    break;
                default:
                    warning("Unknown property type: %d",
                            (int) prop.get_prop_type());
                    break;
            }
            if (prop.get_prop_type() != IBus.PropType.RADIO)
                last_radio = null;
            if (item != null) {
                append(item as Gtk.MenuItem);
                item.property_activate.connect((w, k, s) =>
                                               property_activate(k, s));
                m_items += item;
            }
        }
    }

#if !VALA_0_34
    private void menu_position(Gtk.Menu menu,
                               out int  x,
                               out int  y,
                               out bool push_in) {
        var button = m_parent_button;
        var screen = button.get_screen();
        var monitor = screen.get_monitor_at_window(button.get_window());

        Gdk.Rectangle monitor_location;
        screen.get_monitor_geometry(monitor, out monitor_location);

        button.get_window().get_origin(out x, out y);

        Gtk.Allocation button_allocation;
        button.get_allocation(out button_allocation);

        x += button_allocation.x;
        y += button_allocation.y;

        int menu_width;
        int menu_height;
        menu.get_size_request(out menu_width, out menu_height);

        if (x + menu_width >= monitor_location.width)
            x -= menu_width - button_allocation.width;
        else if (x - menu_width <= 0)
            ;
        else {
            if (x <= monitor_location.width * 3 / 4)
                ;
            else
                x -= menu_width - button_allocation.width;
        }

        if (y + button_allocation.height + menu_width
                >= monitor_location.height)
            y -= menu_height;
        else if (y - menu_height <= 0)
            y += button_allocation.height;
        else {
            if (y <= monitor_location.height * 3 / 4)
                y += button_allocation.height;
            else
                y -= menu_height;
        }

        push_in = false;
    }
#endif
}

public class PropToolButton : Gtk.ToolButton, IPropToolItem {
    private IBus.Property m_prop = null;

    public PropToolButton(IBus.Property prop) {
        /* Chain up base class constructor
         *
         * If the constructor sets "label" property, "halign" property
         * does not work in KDE5 so use sync() for the label.
         */
        GLib.Object(halign: Gtk.Align.START);

        m_prop = prop;

        set_homogeneous(false);
        sync();
    }

    public void update_property(IBus.Property prop) {
        if (m_prop.get_key() != prop.get_key())
            return;
        m_prop.set_symbol(prop.get_symbol());
        m_prop.set_tooltip(prop.get_tooltip());
        m_prop.set_sensitive(prop.get_sensitive());
        m_prop.set_icon(prop.get_icon());
        m_prop.set_state(prop.get_state());
        m_prop.set_visible(prop.get_visible());
        sync();
    }

    private void sync() {
        set_label(m_prop.get_symbol().get_text());
        set_tooltip_text(m_prop.get_tooltip().get_text());
        set_sensitive(m_prop.get_sensitive());
        set_icon_name(m_prop.get_icon());

        if (m_prop.get_visible())
            show();
        else
            hide();
    }

    public override void clicked() {
        property_activate(m_prop.get_key(), m_prop.get_state());
    }

    public new void set_icon_name(string icon_name) {
        string label = m_prop.get_symbol().get_text();
        IconWidget icon_widget = null;

        if (label == "") {
            label = null;
            icon_widget = new IconWidget(icon_name, Gtk.IconSize.BUTTON);
            set_is_important(false);
        } else {
            set_is_important(true);
        }

        set_icon_widget(icon_widget);
    }
}

public class PropToggleToolButton : Gtk.ToggleToolButton, IPropToolItem {
    private IBus.Property m_prop = null;

    public PropToggleToolButton(IBus.Property prop) {
        /* Chain up base class constructor
         *
         * Need to set halign for KDE5
         */
        GLib.Object(halign: Gtk.Align.START);

        m_prop = prop;

        set_homogeneous(false);
        sync();
    }

    public new void set_property(IBus.Property prop) {
        m_prop = prop;
        sync();
    }

    public void update_property(IBus.Property prop) {
        if (m_prop.get_key() != prop.get_key())
            return;
        m_prop.set_symbol(prop.get_symbol());
        m_prop.set_tooltip(prop.get_tooltip());
        m_prop.set_sensitive(prop.get_sensitive());
        m_prop.set_icon(prop.get_icon());
        m_prop.set_state(prop.get_state());
        m_prop.set_visible(prop.get_visible());
        sync();
    }

    private void sync() {
        set_label(m_prop.get_symbol().get_text());
        set_tooltip_text(m_prop.get_tooltip().get_text());
        set_sensitive(m_prop.get_sensitive());
        set_icon_name(m_prop.get_icon());
        set_active(m_prop.get_state() == IBus.PropState.CHECKED);

        if (m_prop.get_visible())
            show();
        else
            hide();
    }

    public override void toggled() {
        /* Do not send property-activate to engine in case the event is
         * sent from engine. */

        bool do_emit = false;

        if (get_active()) {
            if (m_prop.get_state() != IBus.PropState.CHECKED)
                do_emit = true;
            m_prop.set_state(IBus.PropState.CHECKED);
        } else {
            if (m_prop.get_state() != IBus.PropState.UNCHECKED)
                do_emit = true;
            m_prop.set_state(IBus.PropState.UNCHECKED);
        }

        if (do_emit)
            property_activate(m_prop.get_key(), m_prop.get_state());
    }

    public new void set_icon_name(string icon_name) {
        string label = m_prop.get_symbol().get_text();
        IconWidget icon_widget = null;

        if (label == "") {
            label = null;
            icon_widget = new IconWidget(icon_name, Gtk.IconSize.BUTTON);
            set_is_important(false);
        } else {
            set_is_important(true);
        }

        set_icon_widget(icon_widget);
    }
}

public class PropMenuToolButton : PropToggleToolButton, IPropToolItem {
    private PropMenu m_menu = null;

    public PropMenuToolButton(IBus.Property prop) {
        /* Chain up base class constructor
         *
         * Need to set halign for KDE5
         */
        GLib.Object(halign: Gtk.Align.START);

        m_menu = new PropMenu(prop);
        m_menu.deactivate.connect((m) =>
                                  set_active(false));
        m_menu.property_activate.connect((k, s) =>
                                         property_activate(k, s));

        base.set_property(prop);
    }

    public new void update_property(IBus.Property prop) {
        base.update_property(prop);
        m_menu.update_property(prop);
    }

    public override void toggled() {
        if (get_active())
            m_menu.popup(0, Gtk.get_current_event_time(), this);
    }

    public override void destroy() {
        m_menu = null;
        base.destroy();
    }
}

public class PropSeparatorToolItem : Gtk.SeparatorToolItem, IPropToolItem {
    public PropSeparatorToolItem(IBus.Property prop) {
        /* Chain up base class constructor */
        GLib.Object();

        set_homogeneous(false);
    }

    public void update_property(IBus.Property prop) {
    }
}