github.com/wtfutil/wtf@v0.43.0/modules/todo/widget.go (about) 1 package todo 2 3 import ( 4 "fmt" 5 "os" 6 "regexp" 7 "strconv" 8 "strings" 9 "time" 10 11 "github.com/gdamore/tcell/v2" 12 "github.com/rivo/tview" 13 "github.com/wtfutil/wtf/cfg" 14 "github.com/wtfutil/wtf/checklist" 15 "github.com/wtfutil/wtf/utils" 16 "github.com/wtfutil/wtf/view" 17 "github.com/wtfutil/wtf/wtf" 18 "gopkg.in/yaml.v2" 19 ) 20 21 const ( 22 modalHeight = 7 23 modalWidth = 80 24 offscreen = -1000 25 ) 26 27 // A Widget represents a Todo widget 28 type Widget struct { 29 filePath string 30 list checklist.Checklist 31 pages *tview.Pages 32 settings *Settings 33 showTagPrefix string 34 showFilter string 35 tviewApp *tview.Application 36 Error string 37 38 view.ScrollableWidget 39 40 // redrawChan chan bool 41 } 42 43 // NewWidget creates a new instance of a widget 44 func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { 45 widget := Widget{ 46 ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), 47 48 tviewApp: tviewApp, 49 settings: settings, 50 filePath: settings.filePath, 51 showTagPrefix: "", 52 list: checklist.NewChecklist(settings.Sigils.Checkbox.Checked, settings.Sigils.Checkbox.Unchecked), 53 pages: pages, 54 55 // redrawChan: redrawChan, 56 } 57 58 widget.init() 59 60 widget.initializeKeyboardControls() 61 62 widget.View.SetRegions(true) 63 widget.View.SetScrollable(true) 64 65 widget.SetRenderFunction(widget.display) 66 67 return &widget 68 } 69 70 /* -------------------- Exported Functions -------------------- */ 71 72 // SelectedItem returns the currently-selected checklist item or nil if no item is selected 73 func (widget *Widget) SelectedItem() *checklist.ChecklistItem { 74 var selectedItem *checklist.ChecklistItem 75 if widget.isItemSelected() { 76 selectedItem = widget.list.Items[widget.Selected] 77 } 78 79 return selectedItem 80 } 81 82 // Refresh updates the data for this widget and displays it onscreen 83 func (widget *Widget) Refresh() { 84 widget.Error = "" 85 err := widget.load() 86 if err != nil { 87 widget.Error = err.Error() 88 } 89 widget.display() 90 } 91 92 func (widget *Widget) SetList(list checklist.Checklist) { 93 widget.list = list 94 } 95 96 /* -------------------- Unexported Functions -------------------- */ 97 98 func (widget *Widget) init() { 99 _, err := cfg.CreateFile(widget.filePath) 100 if err != nil { 101 return 102 } 103 } 104 105 // isItemSelected returns whether any item of the todo is selected or not 106 func (widget *Widget) isItemSelected() bool { 107 return widget.Selected >= 0 && widget.Selected < len(widget.list.Items) 108 } 109 110 // Loads the todo list from3 Yaml file 111 func (widget *Widget) load() error { 112 confDir, _ := cfg.WtfConfigDir() 113 filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath) 114 115 fileData, err := utils.ReadFileBytes(filePath) 116 117 if err != nil { 118 return err 119 } 120 121 err = yaml.Unmarshal(fileData, &widget.list) 122 if err != nil { 123 return err 124 } 125 126 // do initial sort based on dates to make sure everything is correct 127 if widget.settings.parseDates { 128 i := 0 129 for i < widget.list.Len() { 130 for { 131 newIndex := widget.placeItemBasedOnDate(i) 132 if newIndex == i { 133 break 134 } 135 } 136 i += 1 137 } 138 } 139 140 widget.ScrollableWidget.SetItemCount(len(widget.list.Items)) 141 widget.setItemChecks() 142 return nil 143 } 144 145 func (widget *Widget) newItem() { 146 widget.processFormInput("New Todo:", "", func(t string) { 147 text, date, tags := widget.getTextComponents(t) 148 149 widget.list.Add(false, date, tags, text, widget.settings.newPos) 150 widget.SetItemCount(len(widget.list.Items)) 151 if widget.settings.parseDates { 152 if widget.settings.newPos == "first" { 153 widget.placeItemBasedOnDate(0) 154 } else { 155 widget.placeItemBasedOnDate(widget.list.Len() - 1) 156 } 157 } 158 widget.persist() 159 }) 160 } 161 162 func (widget *Widget) getTextComponents(text string) (string, *time.Time, []string) { 163 var date *time.Time = nil 164 if widget.settings.parseDates { 165 text, date = widget.getTextAndDate(text) 166 } 167 168 tags := make([]string, 0) 169 if widget.settings.parseTags { 170 text, tags = getTodoTags(text) 171 } 172 173 text = strings.TrimSpace(text) 174 return text, date, tags 175 } 176 177 func getTodoTags(text string) (string, []string) { 178 tags := make([]string, 0) 179 r, _ := regexp.Compile(`(?i)(^|\s)#[a-z0-9]+`) 180 matches := r.FindAllString(text, -1) 181 182 for _, tag := range matches { 183 tag = strings.TrimSpace(tag) 184 suffix := " " 185 if strings.HasSuffix(text, tag) { 186 suffix = "" 187 } 188 text = strings.Replace(text, tag+suffix, "", 1) 189 tags = append(tags, tag[1:]) 190 } 191 192 return text, tags 193 } 194 195 type PatternDuration struct { 196 pattern string 197 d int 198 m int 199 y int 200 } 201 202 func (widget *Widget) getTextAndDate(text string) (string, *time.Time) { 203 now := time.Now() 204 textLower := strings.ToLower(text) 205 // check for "in X days/weeks/months/years" pattern 206 r, _ := regexp.Compile("(?i)^in [0-9]+ (day|week|month|year)(s|)") 207 match := r.FindString(text) 208 if len(match) > 0 && len(text) > len(match) { 209 parts := strings.Split(text, " ") 210 n, _ := strconv.Atoi(parts[1]) 211 unit := parts[2][:1] 212 var target time.Time 213 if unit == "d" { 214 target = now.AddDate(0, 0, n) 215 } else if unit == "w" { 216 target = now.AddDate(0, 0, 7*n) 217 } else if unit == "m" { 218 target = now.AddDate(0, n, 0) 219 } else { 220 target = now.AddDate(n, 0, 0) 221 } 222 return text[len(match):], &target 223 } 224 225 // check for "today / tomorrow / next X" 226 patterns := [...]PatternDuration{ 227 {pattern: "today", d: 0, m: 0, y: 0}, 228 {pattern: "tomorrow", d: 1, m: 0, y: 0}, 229 {pattern: "next week", d: 7, m: 0, y: 0}, 230 {pattern: "next month", d: 0, m: 1, y: 0}, 231 {pattern: "next year", d: 0, m: 0, y: 1}, 232 } 233 for _, pd := range patterns { 234 if strings.HasPrefix(textLower, pd.pattern) && len(text) > len(pd.pattern) { 235 date := now.AddDate(pd.y, pd.m, pd.d) 236 return text[len(pd.pattern):], &date 237 } 238 } 239 240 // check for "next X" where X is name of a day (monday, etc) 241 if strings.HasPrefix(textLower, "next") { 242 parts := strings.Split(textLower, " ") 243 if parts[0] == "next" && len(parts) > 2 { 244 for i, d := range []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} { 245 if strings.ToLower(parts[1]) == d { 246 date := now.AddDate(0, 0, int(now.Weekday())+7-i) 247 return text[len(d)+5:], &date 248 } 249 } 250 } 251 } 252 253 // check for YYYY-MM-DD prefix 254 if len(text) > 10 { 255 date, err := time.Parse("2006-01-02", text[:10]) 256 if err == nil { 257 return text[10:], &date 258 } 259 } 260 261 // check for MM-DD prefix 262 if len(text) > 5 { 263 date, err := time.Parse("2006-01-02", strconv.FormatInt(int64(now.Year()), 10)+"-"+text[:5]) 264 if err == nil { 265 return text[5:], &date 266 } 267 } 268 269 return text, nil 270 } 271 272 // persist writes the todo list to Yaml file 273 func (widget *Widget) persist() { 274 confDir, _ := cfg.WtfConfigDir() 275 filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath) 276 277 fileData, _ := yaml.Marshal(&widget.list) 278 279 err := os.WriteFile(filePath, fileData, 0644) 280 281 if err != nil { 282 panic(err) 283 } 284 } 285 286 // setItemChecks rolls through the checklist and ensures that all checklist 287 // items have the correct checked/unchecked icon per the user's preferences 288 func (widget *Widget) setItemChecks() { 289 for _, item := range widget.list.Items { 290 item.CheckedIcon = widget.settings.checked 291 item.UncheckedIcon = widget.settings.unchecked 292 } 293 } 294 295 // updateSelected sets the text of the currently-selected item to the provided text 296 func (widget *Widget) updateSelected() { 297 if !widget.isItemSelected() { 298 return 299 } 300 301 widget.processFormInput("Edit:", widget.SelectedItem().EditText(), func(t string) { 302 text, date, tags := widget.getTextComponents(t) 303 304 widget.updateSelectedItem(text, date, tags) 305 if widget.settings.parseDates { 306 widget.Selected = widget.placeItemBasedOnDate(widget.Selected) 307 } 308 widget.persist() 309 }) 310 } 311 312 // processFormInput is a helper function that creates a form and calls onSave on the received input 313 func (widget *Widget) processFormInput(prompt string, initValue string, onSave func(string)) { 314 form := widget.modalForm(prompt, initValue) 315 316 saveFctn := func() { 317 onSave(form.GetFormItem(0).(*tview.InputField).GetText()) 318 319 widget.pages.RemovePage("modal") 320 widget.tviewApp.SetFocus(widget.View) 321 widget.display() 322 } 323 324 widget.addButtons(form, saveFctn) 325 widget.modalFocus(form) 326 327 // Tell the app to force redraw the screen 328 widget.Base.RedrawChan <- true 329 } 330 331 // updateSelectedItem update the text of the selected item. 332 func (widget *Widget) updateSelectedItem(text string, date *time.Time, tags []string) { 333 selectedItem := widget.SelectedItem() 334 if selectedItem == nil { 335 return 336 } 337 338 selectedItem.Text = text 339 selectedItem.Date = date 340 selectedItem.Tags = tags 341 } 342 343 func (widget *Widget) placeItemBasedOnDate(index int) int { 344 // potentially move todo up 345 for index > 0 && widget.todoDateIsEarlier(index, index-1) { 346 widget.list.Swap(index, index-1) 347 index -= 1 348 } 349 // potentially move todo down 350 for index < widget.list.Len()-1 && widget.todoDateIsEarlier(index+1, index) { 351 widget.list.Swap(index, index+1) 352 index += 1 353 } 354 return index 355 } 356 357 func (widget *Widget) todoDateIsEarlier(i, j int) bool { 358 if widget.list.Items[i].Date == nil && widget.list.Items[j].Date == nil { 359 return false 360 } 361 defaultVal := getNowDate().AddDate(0, 0, widget.settings.undatedAsDays) 362 if widget.list.Items[i].Date == nil { 363 return defaultVal.Before(*widget.list.Items[j].Date) 364 } else if widget.list.Items[j].Date == nil { 365 return widget.list.Items[i].Date.Before(defaultVal) 366 } else { 367 return widget.list.Items[i].Date.Before(*widget.list.Items[j].Date) 368 } 369 } 370 371 /* -------------------- Modal Form -------------------- */ 372 373 func (widget *Widget) addButtons(form *tview.Form, saveFctn func()) { 374 widget.addSaveButton(form, saveFctn) 375 widget.addCancelButton(form) 376 } 377 378 func (widget *Widget) addCancelButton(form *tview.Form) { 379 cancelFn := func() { 380 widget.pages.RemovePage("modal") 381 widget.tviewApp.SetFocus(widget.View) 382 widget.display() 383 } 384 385 form.AddButton("Cancel", cancelFn) 386 form.SetCancelFunc(cancelFn) 387 } 388 389 func (widget *Widget) addSaveButton(form *tview.Form, fctn func()) { 390 form.AddButton("Save", fctn) 391 } 392 393 func (widget *Widget) modalFocus(form *tview.Form) { 394 frame := widget.modalFrame(form) 395 widget.pages.AddPage("modal", frame, false, true) 396 widget.tviewApp.SetFocus(frame) 397 398 // Tell the app to force redraw the screen 399 widget.Base.RedrawChan <- true 400 } 401 402 func (widget *Widget) modalForm(lbl, text string) *tview.Form { 403 form := tview.NewForm() 404 form.SetFieldBackgroundColor(wtf.ColorFor(widget.settings.Colors.Background)) 405 form.SetButtonsAlign(tview.AlignCenter) 406 form.SetButtonTextColor(wtf.ColorFor(widget.settings.Colors.Text)) 407 408 form.AddInputField(lbl, text, 60, nil, nil) 409 410 return form 411 } 412 413 func (widget *Widget) modalFrame(form *tview.Form) *tview.Frame { 414 frame := tview.NewFrame(form) 415 frame.SetBorders(0, 0, 0, 0, 0, 0) 416 frame.SetRect(offscreen, offscreen, modalWidth, modalHeight) 417 frame.SetBorder(true) 418 frame.SetBorders(1, 1, 0, 0, 1, 1) 419 420 drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { 421 w, h := screen.Size() 422 frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height) 423 return x, y, width, height 424 } 425 426 frame.SetDrawFunc(drawFunc) 427 428 return frame 429 }