1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU Lesser General Public License
4 * (version 2.1 or later).  See the COPYING file in this distribution.
5 */
6
7public class SpellCheckPopover {
8
9    /**
10     * This signal is emitted then the selection of rows changes.
11     *
12     * @param active_langs The new set of active dictionaries after the
13     *                     selection has changed.
14     */
15    public signal void selection_changed(string[] active_langs);
16
17    private Gtk.Popover? popover = null;
18    private GLib.GenericSet<string> selected_rows;
19    private bool is_expanded = false;
20    private Gtk.ListBox langs_list;
21    private Gtk.SearchEntry search_box;
22    private Gtk.ScrolledWindow view;
23    private Gtk.Box content;
24    private Application.Configuration config;
25
26    private enum SpellCheckStatus {
27        INACTIVE,
28        ACTIVE
29    }
30
31    private class SpellCheckLangRow : Gtk.ListBoxRow {
32
33        public string lang_code { get; private set; }
34
35        private string lang_name;
36        private string country_name;
37        private bool is_lang_visible;
38        private Gtk.Image active_image;
39        private Gtk.Button visibility_button;
40        private SpellCheckStatus lang_active = SpellCheckStatus.INACTIVE;
41
42        /**
43         * Emitted when the language has been enabled or disabled.
44         */
45        public signal void enabled_changed(bool is_enabled);
46
47        /**
48         * @brief Signal when the visibility has changed.
49         */
50        public signal void visibility_changed(bool is_visible);
51
52
53        public SpellCheckLangRow(string lang_code,
54                                 bool is_active,
55                                 bool is_visible) {
56            this.lang_code = lang_code;
57            this.lang_active = is_active
58                ? SpellCheckStatus.ACTIVE
59                : SpellCheckStatus.INACTIVE;
60            this.is_lang_visible = is_active || is_visible;
61
62            Gtk.Box box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
63            box.margin = 6;
64            box.margin_start = 12;
65
66            lang_name = Util.I18n.language_name_from_locale(lang_code);
67            country_name = Util.I18n.country_name_from_locale(lang_code);
68
69            string label_text = lang_name;
70            Gtk.Label label = new Gtk.Label(label_text);
71            label.tooltip_text = label_text;
72            label.halign = Gtk.Align.START;
73            label.ellipsize = END;
74            label.xalign = 0;
75
76            if (country_name != null) {
77                Gtk.Box label_box = new Gtk.Box(VERTICAL, 3);
78                Gtk.Label country_label = new Gtk.Label(country_name);
79                country_label.tooltip_text = country_name;
80                country_label.halign = Gtk.Align.START;
81                country_label.ellipsize = END;
82                country_label.xalign = 0;
83                country_label.get_style_context().add_class("dim-label");
84
85                label_box.add(label);
86                label_box.add(country_label);
87                box.pack_start(label_box, false, false);
88            } else {
89                box.pack_start(label, false, false);
90            }
91
92            Gtk.IconSize sz = Gtk.IconSize.SMALL_TOOLBAR;
93            active_image = new Gtk.Image.from_icon_name("object-select-symbolic", sz);
94            this.visibility_button = new Gtk.Button();
95            this.visibility_button.set_relief(Gtk.ReliefStyle.NONE);
96            box.pack_start(active_image, false, false, 6);
97            box.pack_start(this.visibility_button, true, true);
98            this.visibility_button.halign = Gtk.Align.END; // Make the button stay at the right end of the screen
99            this.visibility_button.valign = CENTER;
100
101            this.visibility_button.clicked.connect(on_visibility_clicked);
102
103            update_images();
104            add(box);
105        }
106
107        public bool is_lang_active() {
108            return lang_active == SpellCheckStatus.ACTIVE;
109        }
110
111        private void update_images() {
112            Gtk.IconSize sz = Gtk.IconSize.SMALL_TOOLBAR;
113
114            switch (lang_active) {
115            case SpellCheckStatus.ACTIVE:
116                active_image.set_from_icon_name("object-select-symbolic", sz);
117                break;
118            case SpellCheckStatus.INACTIVE:
119                active_image.clear();
120                break;
121            }
122
123            if (is_lang_visible) {
124                this.visibility_button.set_image(new Gtk.Image.from_icon_name("list-remove-symbolic", sz));
125                this.visibility_button.set_tooltip_text(_("Remove this language from the preferred list"));
126            }
127            else {
128                this.visibility_button.set_image(new Gtk.Image.from_icon_name("list-add-symbolic", sz));
129                this.visibility_button.set_tooltip_text(_("Add this language to the preferred list"));
130            }
131        }
132
133        public bool match_filter(string filter) {
134            string filter_down = filter.down();
135            return ((lang_name != null ? filter_down in lang_name.down() : false) ||
136                    (country_name != null ? filter_down in country_name.down() : false));
137        }
138
139        private void set_lang_active(SpellCheckStatus active) {
140            this.lang_active = active;
141
142            switch (active) {
143                case SpellCheckStatus.ACTIVE:
144                    // If the lang is not visible make it visible now
145                    if (!this.is_lang_visible) {
146                        set_lang_visible(true);
147                    }
148                    break;
149                case SpellCheckStatus.INACTIVE:
150                    break;
151            }
152
153            update_images();
154            this.enabled_changed(active == SpellCheckStatus.ACTIVE);
155        }
156
157        private void set_lang_visible(bool is_visible) {
158            this.is_lang_visible = is_visible;
159
160            update_images();
161            if (!this.is_lang_visible &&
162                this.lang_active == SpellCheckStatus.ACTIVE) {
163                set_lang_active(SpellCheckStatus.INACTIVE);
164            }
165
166            visibility_changed(is_visible);
167        }
168
169        public void handle_activation(SpellCheckPopover spell_check_popover) {
170            // Make sure that we do not enable the language when the user is just
171            // trying to remove it from the list.
172            if (!visible)
173                return;
174
175            switch (lang_active) {
176                case SpellCheckStatus.ACTIVE:
177                    set_lang_active(SpellCheckStatus.INACTIVE);
178                    break;
179                case SpellCheckStatus.INACTIVE:
180                    set_lang_active(SpellCheckStatus.ACTIVE);
181                    break;
182            }
183        }
184
185        public bool is_row_visible(bool is_expanded) {
186            return is_lang_visible || is_expanded;
187        }
188
189        private void on_visibility_clicked() {
190            set_lang_visible(!this.is_lang_visible);
191        }
192
193    }
194
195    public SpellCheckPopover(Gtk.MenuButton button, Application.Configuration config) {
196        this.popover = new Gtk.Popover(button);
197        button.popover = this.popover;
198        this.config = config;
199        this.selected_rows = new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
200        setup_popover();
201    }
202
203    private void header_function(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) {
204        if (before != null) {
205            if (row.get_header() == null) {
206                row.set_header(new Gtk.Separator(HORIZONTAL));
207            }
208        }
209    }
210
211    private bool filter_function (Gtk.ListBoxRow row) {
212        string text = search_box.get_text();
213        SpellCheckLangRow r = row as SpellCheckLangRow;
214        return (r.is_row_visible(is_expanded) && r.match_filter(text));
215    }
216
217    private void setup_popover() {
218        // We populate the popover with the list of languages that the user wants to see
219        string[] languages = Util.I18n.get_available_dictionaries();
220        string[] enabled_langs = this.config.get_spell_check_languages();
221        string[] visible_langs = this.config.get_spell_check_visible_languages();
222
223        content = new Gtk.Box(Gtk.Orientation.VERTICAL, 6);
224        search_box = new Gtk.SearchEntry();
225        search_box.set_placeholder_text(_("Search for more languages"));
226        search_box.changed.connect(on_search_box_changed);
227        search_box.grab_focus.connect(on_search_box_grab_focus);
228        content.pack_start(search_box, false, true);
229
230        view = new Gtk.ScrolledWindow(null, null);
231        view.set_shadow_type(Gtk.ShadowType.IN);
232        view.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
233
234        langs_list = new Gtk.ListBox();
235        langs_list.set_selection_mode(Gtk.SelectionMode.NONE);
236        foreach (string lang in languages) {
237            SpellCheckLangRow row = new SpellCheckLangRow(
238                lang,
239                lang in enabled_langs,
240                lang in visible_langs
241            );
242            langs_list.add(row);
243
244            if (row.is_lang_active())
245                selected_rows.add(lang);
246
247            row.enabled_changed.connect(this.on_row_enabled_changed);
248            row.visibility_changed.connect(this.on_row_visibility_changed);
249        }
250        langs_list.row_activated.connect(on_row_activated);
251        view.add(langs_list);
252
253        content.pack_start(view, true, true);
254
255        langs_list.set_filter_func(this.filter_function);
256        langs_list.set_header_func(this.header_function);
257
258        popover.add(content);
259
260        // Make sure that the search box does not get the focus first. We want it to have it only
261        // if the user wants to perform an extended search.
262        content.set_focus_child(view);
263        content.set_margin_start(6);
264        content.set_margin_end(6);
265        content.set_margin_top(6);
266        content.set_margin_bottom(6);
267
268        popover.show.connect(this.on_shown);
269        popover.set_size_request(360, 350);
270    }
271
272    private void on_row_activated(Gtk.ListBoxRow row) {
273        SpellCheckLangRow r = row as SpellCheckLangRow;
274        r.handle_activation(this);
275        // Make sure that we update the visible languages based on the
276        // possibly updated is_lang_visible_properties.
277        langs_list.invalidate_filter();
278    }
279
280    private void on_search_box_changed() {
281        langs_list.invalidate_filter();
282    }
283
284    private void on_search_box_grab_focus() {
285        set_expanded(true);
286    }
287
288    private void set_expanded(bool expanded) {
289        is_expanded = expanded;
290        langs_list.invalidate_filter();
291    }
292
293    private void on_shown() {
294        search_box.set_text("");
295        content.set_focus_child(view);
296        is_expanded = false;
297        langs_list.invalidate_filter();
298
299        popover.show_all();
300    }
301
302    private void on_row_enabled_changed(SpellCheckLangRow row,
303                                        bool is_active) {
304        string lang = row.lang_code;
305        if (is_active) {
306            selected_rows.add(lang);
307        } else {
308            selected_rows.remove(lang);
309        }
310
311        // Signal that the selection has changed
312        string[] active_langs = {};
313        selected_rows.foreach((lang) => active_langs += lang);
314        this.selection_changed(active_langs);
315    }
316
317    private void on_row_visibility_changed(SpellCheckLangRow row,
318                                           bool is_visible) {
319        langs_list.invalidate_filter();
320
321        string[] visible_langs = this.config.get_spell_check_visible_languages();
322        string lang = row.lang_code;
323        if (is_visible) {
324            if (!(lang in visible_langs)) {
325                visible_langs += lang;
326            }
327        } else {
328            string[] new_langs = {};
329            foreach (string lang_code in visible_langs) {
330                if (lang != lang_code) {
331                    new_langs += lang_code;
332                }
333            }
334            visible_langs = new_langs;
335        }
336        this.config.set_spell_check_visible_languages(visible_langs);
337    }
338
339}
340