1/* Copyright 2017-2020 GoForIt! developers
2*
3* This file is part of GoForIt!.
4*
5* GoForIt! is free software: you can redistribute it
6* and/or modify it under the terms of version 3 of the
7* GNU General Public License as published by the Free Software Foundation.
8*
9* GoForIt! is distributed in the hope that it will be
10* useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
11* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
12* Public License for more details.
13*
14* You should have received a copy of the GNU General Public License along
15* with GoForIt!. If not, see http://www.gnu.org/licenses/.
16*/
17
18using GOFI.TXT.TxtUtils;
19
20class GOFI.TXT.TaskRow: DragListRow {
21    private Gtk.CheckButton check_button;
22    private Gtk.Button delete_button;
23    private DynOrientationBox label_box;
24    private TaskMarkupLabel markup_label;
25    private Gtk.Label status_label;
26    private TaskEditEntry edit_entry;
27    private bool editing;
28    private bool focus_cooldown_active;
29    private const string FILTER_PREFIX = "gofi:";
30
31    public bool is_editing {
32        get {
33            return editing;
34        }
35    }
36
37    public TxtTask task {
38        get;
39        private set;
40    }
41
42    public signal void link_clicked (string uri);
43    public signal void deletion_requested ();
44
45    public TaskRow (TxtTask task) {
46        this.task = task;
47
48        edit_entry = null;
49        editing = false;
50        focus_cooldown_active = false;
51        markup_label = new TaskMarkupLabel (task);
52        markup_label.halign = Gtk.Align.START;
53        markup_label.valign = Gtk.Align.BASELINE;
54        status_label = new Gtk.Label (null);
55        status_label.halign = Gtk.Align.END;
56        status_label.valign = Gtk.Align.BASELINE;
57        status_label.use_markup = true;
58        update_status_label ();
59
60        label_box = new DynOrientationBox (2, 0);
61        label_box.set_primary_widget (markup_label);
62        label_box.set_secondary_widget (status_label);
63        label_box.valign = Gtk.Align.BASELINE;
64
65        check_button = new Gtk.CheckButton ();
66        check_button.active = task.done;
67        var sc = kbsettings.get_shortcut (KeyBindingSettings.SCK_MARK_TASK_DONE);
68        check_button.tooltip_markup = sc.get_accel_markup (_("Mark the task as complete"));
69
70        set_start_widget (check_button);
71        set_center_widget (label_box);
72
73        connect_signals ();
74        show_all ();
75    }
76
77    public override void show_all () {
78        bool status_label_was_visible = status_label.visible;
79        base.show_all ();
80        if (!status_label_was_visible) {
81            status_label.hide ();
82        }
83    }
84
85    public void edit (bool wrestle_focus=false) {
86        if (edit_entry != null) {
87            return;
88        }
89        delete_button = new Gtk.Button.from_icon_name ("edit-delete", Gtk.IconSize.MENU);
90        delete_button.relief = Gtk.ReliefStyle.NONE;
91        delete_button.show_all ();
92        delete_button.clicked.connect (on_delete_button_clicked);
93        set_start_widget (delete_button);
94
95        edit_entry = new TaskEditEntry (task.to_simple_txt ());
96        set_center_widget (edit_entry);
97
98        edit_entry.edit ();
99        edit_entry.string_changed.connect (on_edit_entry_string_changed);
100        edit_entry.editing_finished.connect (on_edit_entry_finished);
101        editing = true;
102
103        if (wrestle_focus) {
104            // Ugly hack: on Gtk 3.22 the row will steal focus from the entry in
105            // about 0.1s if the row has been activated using a double-click
106            // we want the entry to remain in focus until the user decides
107            // otherwise.
108            edit_entry.hold_focus = true;
109            GLib.Timeout.add (
110                200, release_focus_claim, GLib.Priority.DEFAULT_IDLE
111            );
112        }
113    }
114
115    private void on_delete_button_clicked () {
116        deletion_requested ();
117    }
118
119    private void on_edit_entry_string_changed () {
120        task.update_from_simple_txt (edit_entry.text.strip ());
121    }
122
123    private void on_edit_entry_finished () {
124        stop_editing ();
125    }
126
127    /**
128     * Using a cooldown to work around a Gtk issue:
129     * The ListBoxRow will steal focus again after activating and in addition
130     * to that for a moment neither the row nor the entry may have focus.
131     * We give everything a moment to settle and stop editing as soon as neither
132     * this row or the entry has focus.
133     */
134    private bool on_focus_out () {
135        if (focus_cooldown_active | !editing) {
136            return false;
137        }
138        focus_cooldown_active = true;
139        GLib.Timeout.add (
140            50, focus_cooldown_end, GLib.Priority.DEFAULT_IDLE
141        );
142        return false;
143    }
144
145    private bool focus_cooldown_end () {
146        focus_cooldown_active = false;
147        if (!editing) {
148            return false;
149        }
150        if (!has_focus && get_focus_child () == null) {
151            stop_editing ();
152            return false;
153        }
154        return GLib.Source.REMOVE;
155    }
156
157    private bool release_focus_claim () {
158        edit_entry.hold_focus = false;
159        return false;
160    }
161
162    public void stop_editing () {
163        if (!editing) {
164            return;
165        }
166        var had_focus = edit_entry.has_focus;
167        set_center_widget (label_box);
168        set_start_widget (check_button);
169        delete_button = null;
170        edit_entry = null;
171        editing = false;
172        if (had_focus) {
173            grab_focus ();
174        }
175    }
176
177    private bool on_row_key_release (Gdk.EventKey event) {
178        switch (event.keyval) {
179            case Gdk.Key.Delete:
180                if (!editing || !edit_entry.has_focus) {
181                    deletion_requested ();
182                    return true;
183                }
184                break;
185            case Gdk.Key.Escape:
186                if (editing) {
187                    stop_editing ();
188                    return true;
189                }
190                break;
191            default:
192                return false;
193        }
194        return false;
195    }
196
197    private void connect_signals () {
198        check_button.toggled.connect (on_check_toggled);
199        markup_label.activate_link.connect (on_activate_link);
200
201        set_focus_child.connect (on_set_focus_child);
202        focus_out_event.connect (on_focus_out);
203        key_release_event.connect (on_row_key_release);
204
205        task.done_changed.connect (on_task_done_changed);
206        task.notify["status"].connect (update_status_label);
207        task.notify["timer-value"].connect (update_status_label);
208    }
209
210    private void on_check_toggled () {
211        task.done = !task.done;
212    }
213
214    private void on_task_done_changed () {
215        destroy ();
216    }
217
218    private bool on_activate_link (string uri) {
219        if (uri.has_prefix (FILTER_PREFIX)) {
220            link_clicked (uri.offset (FILTER_PREFIX.length));
221            return true;
222        }
223        return false;
224    }
225
226    private void on_set_focus_child (Gtk.Widget? widget) {
227        if (widget == null && !has_focus) {
228            on_focus_out ();
229        }
230    }
231
232    private void update_status_label () {
233        var timer_value = task.timer_value;
234        if (task.done && timer_value >= 60) {
235            var timer_value_str = Utils.seconds_to_pretty_string (timer_value);
236            status_label.label = "<i>%s</i>".printf (timer_value_str);
237            status_label.show ();
238        } else if ((task.status & TaskStatus.TIMER_ACTIVE) != 0) {
239            status_label.label = "⏰";
240            status_label.show ();
241        } else {
242            status_label.hide ();
243        }
244    }
245
246    class TaskEditEntry : Gtk.Entry {
247        public signal void editing_finished ();
248        public signal void string_changed ();
249        private uint8 focus_wrestle_counter;
250
251        public bool hold_focus {
252            get {
253                return focus_wrestle_counter != 0;
254            }
255            set {
256                if (value) {
257                    // 1 seems to be sufficient right now
258                    focus_wrestle_counter = 1;
259                } else {
260                    focus_wrestle_counter = 0;
261                }
262            }
263        }
264
265        public TaskEditEntry (string description) {
266            can_focus = true;
267            text = description;
268            focus_wrestle_counter = 0;
269            focus_out_event.connect (() => {
270                if (focus_wrestle_counter == 0) {
271                    return false;
272                }
273                focus_wrestle_counter--;
274                grab_focus ();
275                return false;
276            });
277        }
278
279        private void abort_editing () {
280            editing_finished ();
281        }
282
283        private void stop_editing () {
284            string_changed ();
285            abort_editing ();
286        }
287
288        public void edit () {
289            show ();
290            grab_focus ();
291            activate.connect (stop_editing);
292        }
293    }
294
295    class TaskMarkupLabel : Gtk.Label {
296        private TxtTask task;
297
298        private string markup_string;
299
300        public TaskMarkupLabel (TxtTask task) {
301            this.task = task;
302
303            update ();
304
305            hexpand = true;
306            wrap = true;
307            wrap_mode = Pango.WrapMode.WORD_CHAR;
308#if HAS_GTK322
309            this.xalign = 0f;
310#else
311            // Workaround for: "undefined symbol: gtk_label_set_xalign"
312            ((Gtk.Misc) this).xalign = 0f;
313#endif
314
315            connect_signals ();
316            show_all ();
317        }
318
319        public void update_tooltip () {
320            GOFI.Date? completion_date = task.completion_date;
321            GOFI.Date? creation_date = task.creation_date;
322
323            /// see https://valadoc.org/glib-2.0/GLib.DateTime.format.html for
324            // formatting of DateTime
325            string date_format = _("%Y-%m-%d");
326
327            if (task.done && completion_date != null) {
328                this.tooltip_text =
329                    _("Task completed at %1$s, created at %2$s").printf (
330                        completion_date.dt.format (date_format),
331                        creation_date.dt.format (date_format)
332                    );
333            } else if (creation_date != null) {
334                var timer_value = task.timer_value;
335                var new_tooltip_text = _("Task created at %s").printf (
336                        creation_date.dt.format (date_format)
337                );
338
339                if (timer_value >= 60) {
340                  var timer_value_str = Utils.seconds_to_pretty_string (timer_value);
341                  new_tooltip_text += "\n%s: %s".printf (_("Timer"), timer_value_str);
342                }
343                this.tooltip_text = new_tooltip_text;
344            }
345        }
346
347        private void gen_markup () {
348            markup_string = make_links (task.get_descr_parts ());
349
350            var done = task.done;
351            var duration = task.duration;
352
353            if (task.priority != TxtTask.NO_PRIO) {
354                var prefix = _("priority");
355                var priority = task.priority;
356                char prio_char = priority + 65;
357                markup_string = @"<b><a href=\"$prefix:$prio_char\">($prio_char)</a></b> $markup_string";
358            }
359            if (duration > 0) {
360                var timer_value = task.timer_value;
361                if (timer_value > 0 && !done) {
362                    markup_string = "%s <i>(%u / %s)</i>".printf (
363                        markup_string, timer_value / 60,
364                        Utils.seconds_to_short_string (duration)
365                    );
366                } else {
367                    markup_string = "%s <i>(%s)</i>".printf (
368                        markup_string, Utils.seconds_to_short_string (duration)
369                    );
370                }
371            }
372            if (done) {
373                markup_string = "<s>" + markup_string + "</s>";
374            }
375        }
376
377        /**
378         * Used to find projects and contexts and replace those parts with a
379         * link.
380         * @param description the string to took for contexts or projects
381         */
382        private string make_links (TxtPart[] description) {
383            var length = description.length;
384            var markup_parts = new string[length];
385            string? delimiter = null, prefix = null, val = null;
386
387            for (uint i = 0; i < length; i++) {
388                unowned TxtPart part = description[i];
389                val = GLib.Markup.escape_text (part.content);
390
391                switch (part.part_type) {
392                    case TxtPartType.CONTEXT:
393                        prefix = _("context");
394                        delimiter = "@";
395                        break;
396                    case TxtPartType.PROJECT:
397                        prefix = _("project");
398                        delimiter = "+";
399                        break;
400                    case TxtPartType.URI:
401                        string uri, display_uri;
402                        if (part.tag_name == null || part.tag_name == "") {
403                            uri = part.content;
404                            display_uri = val;
405                        } else {
406                            uri = part.tag_name + ":" + part.content;
407                            display_uri = part.tag_name + ":" + val;
408                        }
409                        markup_parts[i] =
410                            @"<a href=\"$uri\" title=\"$display_uri\">$display_uri</a>";
411                        continue;
412                    case TxtPartType.TAG:
413                        markup_parts[i] = part.tag_name + ":" + val;
414                        continue;
415                    default:
416                        markup_parts[i] = val;
417                        continue;
418                }
419                markup_parts[i] = @" <a href=\"$FILTER_PREFIX$prefix:$val\" title=\"$val\">" +
420                                  @"$delimiter$val</a>";
421            }
422
423            return string.joinv (" ", markup_parts);
424        }
425
426        private void update () {
427            gen_markup ();
428            set_markup (markup_string);
429            update_tooltip ();
430        }
431
432        private void connect_signals () {
433            task.notify["description"].connect (update);
434            task.notify["priority"].connect (update);
435            task.notify["timer-value"].connect (update);
436        }
437    }
438}
439