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  }