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