github.com/xyproto/orbiton/v2@v2.65.12-0.20240516144430-e10a419274ec/table.go (about) 1 package main 2 3 import ( 4 "errors" 5 "os" 6 "os/signal" 7 "strings" 8 "syscall" 9 "time" 10 11 "github.com/xyproto/vt100" 12 ) 13 14 // InTableAt checks if it is likely that the given LineIndex is in a Markdown table 15 func (e *Editor) InTableAt(i LineIndex) bool { 16 line := e.Line(i) 17 return strings.Count(line, "|") > 1 || separatorRow(line) 18 } 19 20 // InTable checks if we are currently in what appears to be a Markdown table 21 func (e *Editor) InTable() bool { 22 line := e.CurrentLine() 23 return strings.Count(line, "|") > 1 || separatorRow(line) 24 } 25 26 // TopOfCurrentTable tries to find the first line index of the current Markdown table 27 func (e *Editor) TopOfCurrentTable() (LineIndex, error) { 28 startIndex := e.DataY() 29 30 if !e.InTableAt(startIndex) { 31 return -1, errors.New("not in a table") 32 } 33 34 index := startIndex 35 for index >= 0 && e.InTableAt(index) { 36 index-- 37 } 38 39 return index + 1, nil 40 } 41 42 // GoToTopOfCurrentTable tries to jump to the first line of the current Markdown table 43 func (e *Editor) GoToTopOfCurrentTable(c *vt100.Canvas, status *StatusBar, centerCursor bool) LineIndex { 44 topIndex, err := e.TopOfCurrentTable() 45 if err != nil { 46 return 0 47 } 48 e.redraw, _ = e.GoTo(topIndex, c, status) 49 if e.redraw && centerCursor { 50 e.Center(c) 51 } 52 return topIndex 53 } 54 55 // CurrentTableY returns the current Y position within the current Markdown table 56 func (e *Editor) CurrentTableY() (int, error) { 57 topIndex, err := e.TopOfCurrentTable() 58 if err != nil { 59 return -1, err 60 } 61 currentIndex := e.DataY() 62 63 indexY := int(currentIndex) - int(topIndex) 64 65 // Count the divider Lines, and subtract those 66 s, err := e.CurrentTableString() 67 if err != nil { 68 return indexY, err 69 } 70 separatorCounter := 0 71 for _, line := range strings.Split(s, "\n") { 72 if separatorRow(line) { 73 separatorCounter++ 74 } 75 } 76 indexY -= separatorCounter 77 78 // just a safeguard 79 if indexY < 0 { 80 indexY = 0 81 } 82 83 return indexY, nil 84 } 85 86 // CurrentTableString returns the current Markdown table as a newline separated string, if possible 87 func (e *Editor) CurrentTableString() (string, error) { 88 index, err := e.TopOfCurrentTable() 89 if err != nil { 90 return "", err 91 } 92 93 var sb strings.Builder 94 for e.InTableAt(index) { 95 trimmedLine := strings.TrimSpace(e.Line(index)) 96 sb.WriteString(trimmedLine + "\n") 97 index++ 98 } 99 100 // Return the collected table lines 101 return sb.String(), nil 102 } 103 104 // DeleteCurrentTable will delete the current Markdown table 105 func (e *Editor) DeleteCurrentTable(c *vt100.Canvas, status *StatusBar, bookmark *Position) (LineIndex, error) { 106 s, err := e.CurrentTableString() 107 if err != nil { 108 return 0, err 109 } 110 lines := strings.Split(s, "\n") 111 if len(lines) == 0 { 112 return 0, errors.New("need at least one line of table to be able to remove it") 113 } 114 const centerCursor = false 115 topOfTable := e.GoToTopOfCurrentTable(c, status, centerCursor) 116 for range lines { 117 e.DeleteLineMoveBookmark(e.LineIndex(), bookmark) 118 } 119 return topOfTable, nil 120 } 121 122 // ReplaceCurrentTableWith will try to replace the current table with the given string. 123 // Also moves the current bookmark, if needed. 124 func (e *Editor) ReplaceCurrentTableWith(c *vt100.Canvas, status *StatusBar, bookmark *Position, tableString string) error { 125 topOfTable, err := e.DeleteCurrentTable(c, status, bookmark) 126 if err != nil { 127 return err 128 } 129 lines := strings.Split(tableString, "\n") 130 addNewLine := false 131 e.InsertBlock(c, lines, addNewLine) 132 e.GoTo(topOfTable, c, status) 133 return nil 134 } 135 136 // separatorRow checks if this string looks like a header/body table separator line in Markdown 137 func separatorRow(s string) bool { 138 notEmpty := false 139 for _, r := range s { 140 switch r { 141 case ' ': 142 case '-', '|', '\n', '\t': 143 notEmpty = true 144 default: 145 return false 146 } 147 } 148 return notEmpty 149 } 150 151 // Parse a Markdown table into a slice of header strings and a [][]slice of rows and columns 152 func parseTable(s string) ([]string, [][]string) { 153 154 var ( 155 headers []string 156 body [][]string 157 ) 158 159 // Is it a Markdown table without leading "|" and trailing "|" on every row? 160 // see https://tableconvert.com/ for an example of the "Use simple Markdown table" style. 161 simpleStyle := strings.Contains(s, "\n--") 162 163 for i, line := range strings.Split(s, "\n") { 164 if strings.TrimSpace(line) == "" { 165 continue 166 } 167 var fields []string 168 if simpleStyle { 169 fields = strings.Split(line, "|") 170 } else { 171 fields = strings.Split(line, "|") 172 if len(fields) > 2 { 173 if strings.TrimSpace(fields[0]) == "" && strings.TrimSpace(fields[len(fields)-1]) == "" { 174 fields = fields[1 : len(fields)-1] // skip the first and last slice entry 175 } 176 } 177 } 178 // Is this a separator row? 179 if separatorRow(line) { 180 // skip 181 continue 182 } 183 // Trim spaces from all the fields 184 for i := 0; i < len(fields); i++ { 185 fields[i] = strings.TrimSpace(fields[i]) 186 } 187 188 // Assign the parsed fields into either headers or the table body 189 if i == 0 { 190 headers = fields 191 } else { 192 body = append(body, fields) 193 } 194 } 195 196 return headers, body 197 } 198 199 // TableColumnWidths returns a slice of max widths for all columns in the given headers+body table 200 func TableColumnWidths(headers []string, body [][]string) []int { 201 maxColumns := len(headers) 202 for _, row := range body { 203 if len(row) > maxColumns { 204 maxColumns = len(row) 205 } 206 } 207 208 columnWidths := make([]int, maxColumns) 209 210 // find the width of the longest string per column 211 for i, field := range headers { 212 if len(field) > columnWidths[i] { 213 columnWidths[i] = len(field) 214 } 215 } 216 217 // find the width of the longest string per column 218 for _, row := range body { 219 for i, field := range row { 220 if len(field) > columnWidths[i] { 221 columnWidths[i] = len(field) 222 } 223 } 224 } 225 return columnWidths 226 } 227 228 // RightTrimColumns removes the last column of the table, if it only consists of empty strings 229 func RightTrimColumns(headers *[]string, body *[][]string) { 230 if len(*headers) == 0 || len(*body) == 0 { 231 return 232 } 233 if len((*body)[0]) == 0 { 234 return 235 } 236 237 // There is at least 1 header, 1 row and 1 column 238 239 // Check if the last header cell is empty 240 if strings.TrimSpace((*headers)[len(*headers)-1]) != "" { 241 return 242 } 243 244 // Check if all the last cells per row are empty 245 for _, row := range *body { 246 if strings.TrimSpace(row[len(row)-1]) != "" { 247 return 248 } 249 } 250 251 // We now know that the last column is empty, for the headers and for all rows 252 253 // Remove the last column of the headers 254 *headers = (*headers)[:len(*headers)-1] 255 256 for i := range *body { 257 // Remove the last column of this row 258 (*body)[i] = (*body)[i][:len((*body)[i])-1] 259 } 260 } 261 262 func tableToString(headers []string, body [][]string) string { 263 264 columnWidths := TableColumnWidths(headers, body) 265 266 var sb strings.Builder 267 268 // First output the headers 269 270 sb.WriteString("|") 271 for i, field := range headers { 272 sb.WriteString(" ") 273 sb.WriteString(field) 274 if len(field) < columnWidths[i] { 275 neededSpaces := columnWidths[i] - len(field) 276 spaces := strings.Repeat(" ", neededSpaces) 277 sb.WriteString(spaces) 278 } 279 sb.WriteString(" |") 280 } 281 sb.WriteString("\n") 282 283 // Then output the separator line 284 285 sb.WriteString("|") 286 for _, neededDashes := range columnWidths { 287 dashes := strings.Repeat("-", neededDashes+2) 288 sb.WriteString(dashes) 289 sb.WriteString("|") 290 } 291 sb.WriteString("\n") 292 293 // Then add the table body 294 295 for _, row := range body { 296 297 // If all fields are empty, then skip this row 298 allEmpty := true 299 for _, field := range row { 300 if strings.TrimSpace(field) != "" { 301 allEmpty = false 302 break 303 } 304 } 305 if allEmpty { 306 continue 307 } 308 309 // Write the fields of this row to the string builder 310 sb.WriteString("|") 311 for i, field := range row { 312 sb.WriteString(" ") 313 sb.WriteString(field) 314 if len(field) < columnWidths[i] { 315 neededSpaces := columnWidths[i] - len(field) 316 spaces := strings.Repeat(" ", neededSpaces) 317 sb.WriteString(spaces) 318 } 319 sb.WriteString(" |") 320 } 321 sb.WriteString("\n") 322 } 323 324 return sb.String() 325 } 326 327 // EditMarkdownTable presents the user with a dedicated table editor for the current Markdown table 328 func (e *Editor) EditMarkdownTable(tty *vt100.TTY, c *vt100.Canvas, status *StatusBar, bookmark *Position, justFormat, displayQuickHelp bool) { 329 330 initialY, err := e.CurrentTableY() 331 if err != nil { 332 status.ClearAll(c) 333 status.SetError(err) 334 status.ShowNoTimeout(c, e) 335 return 336 } 337 338 tableString, err := e.CurrentTableString() 339 if err != nil { 340 status.ClearAll(c) 341 status.SetError(err) 342 status.ShowNoTimeout(c, e) 343 return 344 } 345 346 headers, body := parseTable(tableString) 347 348 tableContents := [][]string{} 349 tableContents = append(tableContents, headers) 350 tableContents = append(tableContents, body...) 351 352 // Make all rows contain as many fields as the longest row 353 Expand(&tableContents) 354 355 contentsChanged := false 356 357 if !justFormat { 358 contentsChanged, err = e.TableEditor(tty, status, &tableContents, initialY, displayQuickHelp) 359 if err != nil { 360 status.ClearAll(c) 361 status.SetError(err) 362 status.ShowNoTimeout(c, e) 363 return 364 } 365 } 366 367 if justFormat || contentsChanged { 368 switch len(tableContents) { 369 case 0: 370 headers = []string{} 371 body = [][]string{} 372 case 1: 373 headers = tableContents[0] 374 body = [][]string{} 375 default: 376 headers = tableContents[0] 377 body = tableContents[1:] 378 } 379 380 RightTrimColumns(&headers, &body) 381 newTableString := tableToString(headers, body) 382 383 // Replace the current table with this new string 384 if err := e.ReplaceCurrentTableWith(c, status, bookmark, newTableString); err != nil { 385 status.ClearAll(c) 386 status.SetError(err) 387 status.ShowNoTimeout(c, e) 388 return 389 } 390 391 } 392 } 393 394 // TableEditor presents an interface for changing the given headers and body 395 // initialY is the initial Y position of the cursor in the table 396 // Returns true if the user changed the contents. 397 func (e *Editor) TableEditor(tty *vt100.TTY, status *StatusBar, tableContents *[][]string, initialY int, displayQuickHelp bool) (bool, error) { 398 399 title := "Markdown Table Editor" 400 titleColor := e.Foreground // HeaderBulletColor 401 headerColor := e.XColor 402 textColor := e.MarkdownTextColor 403 highlightColor := e.MenuArrowColor 404 cursorColor := e.SearchHighlight 405 commentColor := e.CommentColor 406 userChangedTheContents := false 407 408 // Clear the existing handler 409 signal.Reset(syscall.SIGWINCH) 410 411 var ( 412 c = vt100.NewCanvas() 413 tableWidget = NewTableWidget(title, tableContents, titleColor, headerColor, textColor, highlightColor, cursorColor, commentColor, e.Background, int(c.W()), int(c.H()), initialY, displayQuickHelp) 414 sigChan = make(chan os.Signal, 1) 415 running = true 416 changed = true 417 cancel = false 418 ) 419 420 // Set up a new resize handler 421 signal.Notify(sigChan, syscall.SIGWINCH) 422 423 resizeRedrawFunc := func() { 424 // Create a new canvas, with the new size 425 nc := c.Resized() 426 if nc != nil { 427 vt100.Clear() 428 c = nc 429 tableWidget.Draw(c) 430 c.Redraw() 431 changed = true 432 } 433 } 434 435 go func() { 436 for range sigChan { 437 resizeMut.Lock() 438 resizeRedrawFunc() 439 resizeMut.Unlock() 440 } 441 }() 442 443 vt100.Clear() 444 vt100.Reset() 445 c.Redraw() 446 447 showMessage := func(msg string, color vt100.AttributeColor) { 448 msgX := (c.W() - uint(len(msg))) / 2 449 msgY := c.H() - 1 450 c.Write(msgX, msgY, color, e.Background, msg) 451 go func() { 452 time.Sleep(1 * time.Second) 453 s := strings.Repeat(" ", len(msg)) 454 c.Write(msgX, msgY, textColor, e.Background, s) 455 }() 456 } 457 458 for running { 459 460 // Draw elements in their new positions 461 462 if changed { 463 resizeMut.RLock() 464 tableWidget.Draw(c) 465 resizeMut.RUnlock() 466 // Update the canvas 467 c.Draw() 468 } 469 470 // Handle events 471 key := tty.String() 472 switch key { 473 case "↑": // Up 474 resizeMut.Lock() 475 tableWidget.Up() 476 changed = true 477 resizeMut.Unlock() 478 case "←": // Left 479 resizeMut.Lock() 480 tableWidget.Left() 481 changed = true 482 resizeMut.Unlock() 483 case "↓": // Down 484 resizeMut.Lock() 485 tableWidget.Down() 486 changed = true 487 resizeMut.Unlock() 488 case "→": // Right 489 resizeMut.Lock() 490 tableWidget.Right() 491 changed = true 492 resizeMut.Unlock() 493 case "c:9": // Next, tab 494 resizeMut.Lock() 495 tableWidget.NextOrInsert() 496 changed = true 497 resizeMut.Unlock() 498 case "c:1": // Start of row, ctrl-a 499 resizeMut.Lock() 500 tableWidget.SelectStart() 501 changed = true 502 resizeMut.Unlock() 503 case "c:5": // End of row, ctrl-e 504 resizeMut.Lock() 505 tableWidget.SelectEnd() 506 changed = true 507 resizeMut.Unlock() 508 case "c:27", "q", "c:3", "c:17", "c:15", "c:20": // ESC, q, ctrl-c, ctrl-q, ctrl-o or ctrl-t 509 running = false 510 changed = true 511 cancel = true 512 case "c:19": // ctrl-s, save 513 resizeMut.Lock() 514 // Try to save the file 515 if err := e.Save(c, tty); err != nil { 516 // TODO: Use a StatusBar instead, then draw it at the end of the loop 517 showMessage(err.Error(), cursorColor) 518 } else { 519 showMessage("Saved", cursorColor) 520 } 521 changed = true 522 resizeMut.Unlock() 523 case "c:13": // return, insert a row below 524 resizeMut.Lock() 525 if tableWidget.FieldBelowIsEmpty() { 526 tableWidget.Down() 527 } else { 528 tableWidget.InsertRowBelow() 529 changed = true 530 userChangedTheContents = true 531 } 532 resizeMut.Unlock() 533 case "c:14": // ctrl-n, insert column after 534 resizeMut.Lock() 535 tableWidget.InsertColumnAfter() 536 tableWidget.NextOrInsert() 537 changed = true 538 userChangedTheContents = true 539 resizeMut.Unlock() 540 case "c:4", "c:16": // ctrl-d or ctrl-p, delete the current column if all its fields are empty 541 resizeMut.Lock() 542 if err := tableWidget.DeleteCurrentColumnIfEmpty(); err != nil { 543 // TODO: Use a StatusBar instead, then draw it at the end of the loop 544 showMessage(err.Error(), cursorColor) 545 } else { 546 changed = true 547 userChangedTheContents = true 548 } 549 resizeMut.Unlock() 550 case "c:8", "c:127": // ctrl-h or backspace 551 resizeMut.Lock() 552 s := tableWidget.Get() 553 if len(s) > 0 { 554 tableWidget.Set(s[:len(s)-1]) 555 changed = true 556 userChangedTheContents = true 557 } else if tableWidget.CurrentRowIsEmpty() { 558 tableWidget.DeleteCurrentRow() 559 changed = true 560 userChangedTheContents = true 561 } 562 resizeMut.Unlock() 563 default: 564 resizeMut.Lock() 565 if !strings.HasPrefix(key, "c:") { 566 tableWidget.Add(key) 567 changed = true 568 userChangedTheContents = true 569 } 570 resizeMut.Unlock() 571 } 572 573 // If the menu was changed, draw the canvas 574 if changed { 575 c.Draw() 576 } 577 578 if cancel { 579 tableWidget.TrimAll() 580 break 581 } 582 } 583 584 // Restore the signal handlers 585 e.SetUpSignalHandlers(c, tty, status) 586 587 return userChangedTheContents, nil 588 }