2 * Jexer - Java Text User Interface
4 * The MIT License (MIT)
6 * Copyright (C) 2019 Kevin Lamonte
8 * Permission is hereby granted, free of charge, to any person obtaining a
9 * copy of this software and associated documentation files (the "Software"),
10 * to deal in the Software without restriction, including without limitation
11 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
12 * and/or sell copies of the Software, and to permit persons to whom the
13 * Software is furnished to do so, subject to the following conditions:
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
21 * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24 * DEALINGS IN THE SOFTWARE.
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
31 import java
.util
.ArrayList
;
32 import java
.util
.List
;
34 import jexer
.bits
.CellAttributes
;
35 import jexer
.event
.TKeypressEvent
;
36 import jexer
.event
.TMenuEvent
;
37 import jexer
.event
.TResizeEvent
;
38 import jexer
.menu
.TMenu
;
39 import static jexer
.TKeypress
.*;
42 * TTableWidget is used to display and edit regular two-dimensional tables of
45 * This class was inspired by a TTable implementation originally developed by
46 * David "Niki" ROULET [niki@nikiroo.be], made available under MIT at
47 * https://github.com/nikiroo/jexer/tree/ttable_pull.
49 public class TTableWidget
extends TWidget
{
51 // ------------------------------------------------------------------------
52 // Constants --------------------------------------------------------------
53 // ------------------------------------------------------------------------
56 * Available borders for cells.
65 * Single bar: \u2502 (vertical) and \u2500 (horizontal).
70 * Double bar: \u2551 (vertical) and \u2550 (horizontal).
75 * Thick bar: \u258C (vertical, left half block) and \u2580
76 * (horizontal, upper block).
81 // ------------------------------------------------------------------------
82 // Variables --------------------------------------------------------------
83 // ------------------------------------------------------------------------
86 * The underlying data, organized as columns.
88 private ArrayList
<Column
> columns
= new ArrayList
<Column
>();
91 * The underlying data, organized as rows.
93 private ArrayList
<Row
> rows
= new ArrayList
<Row
>();
96 * The row in model corresponding to the top-left visible cell.
101 * The column in model corresponding to the top-left visible cell.
103 private int left
= 0;
106 * The row in model corresponding to the currently selected cell.
108 private int selectedRow
= 0;
111 * The column in model corresponding to the currently selected cell.
113 private int selectedColumn
= 0;
116 * If true, highlight the entire row of the currently-selected cell.
118 private boolean highlightRow
= true;
121 * If true, highlight the entire column of the currently-selected cell.
123 private boolean highlightColumn
= true;
126 * If true, show the row labels as the first column.
128 private boolean showRowLabels
= true;
131 * If true, show the column labels as the first row.
133 private boolean showColumnLabels
= true;
136 * Column represents a column of cells.
138 public class Column
{
143 private int width
= 8;
146 * The cells of this column.
148 private ArrayList
<Cell
> cells
= new ArrayList
<Cell
>();
153 private String label
= "";
156 * The border for this column.
158 private Border border
= Border
.NONE
;
161 * Constructor sets label to lettered column.
163 * @param col column number to use for this column. Column 0 will be
164 * "A", column 1 will be "B", column 26 will be "AA", and so on.
167 StringBuilder sb
= new StringBuilder();
169 sb
.append((char) ('A' + (col
% 26)));
175 label
= sb
.reverse().toString();
179 * Add an entry to this column.
181 * @param cell the cell to add
183 public void add(final Cell cell
) {
188 * Get an entry from this column.
190 * @param row the entry index to get
191 * @return the cell at row
193 public Cell
get(final int row
) {
194 return cells
.get(row
);
199 * Row represents a row of cells.
206 private int height
= 1;
209 * The cells of this row.
211 private ArrayList
<Cell
> cells
= new ArrayList
<Cell
>();
216 private String label
= "";
219 * The border for this row.
221 private Border border
= Border
.NONE
;
224 * Constructor sets label to numbered row.
226 * @param row row number to use for this row
229 label
= Integer
.toString(row
);
233 * Add an entry to this column.
235 * @param cell the cell to add
237 public void add(final Cell cell
) {
242 * Get an entry from this row.
244 * @param column the entry index to get
245 * @return the cell at column
247 public Cell
get(final int column
) {
248 return cells
.get(column
);
254 * Cell represents an editable cell in the table. Normally, navigation
255 * to a cell only highlights it; pressing Enter or F2 will switch to
258 public class Cell
extends TWidget
{
260 // --------------------------------------------------------------------
261 // Variables ----------------------------------------------------------
262 // --------------------------------------------------------------------
265 * The field containing the cell's data.
267 private TField field
;
270 * The column of this cell.
275 * The row of this cell.
280 * If true, the cell is being edited.
282 private boolean isEditing
= false;
285 * Text of field before editing.
287 private String fieldText
;
289 // --------------------------------------------------------------------
290 // Constructors -------------------------------------------------------
291 // --------------------------------------------------------------------
294 * Public constructor.
296 * @param parent parent widget
297 * @param x column relative to parent
298 * @param y row relative to parent
299 * @param width width of widget
300 * @param height height of widget
301 * @param column column index of this cell
302 * @param row row index of this cell
304 public Cell(final TTableWidget parent
, final int x
, final int y
,
305 final int width
, final int height
, final int column
,
308 super(parent
, x
, y
, width
, height
);
309 this.column
= column
;
312 field
= addField(0, 0, width
, false);
313 field
.setEnabled(false);
314 field
.setBackgroundChar(' ');
317 // --------------------------------------------------------------------
318 // Event handlers -----------------------------------------------------
319 // --------------------------------------------------------------------
324 * @param keypress keystroke event
327 public void onKeypress(final TKeypressEvent keypress
) {
328 // System.err.println("Cell onKeypress: " + keypress);
331 if (keypress
.equals(kbEsc
)) {
332 // ESC cancels the edit.
333 field
.setText(fieldText
);
335 field
.setEnabled(false);
338 if (keypress
.equals(kbEnter
)) {
339 // Enter ends editing.
340 fieldText
= field
.getText();
342 field
.setEnabled(false);
345 // Pass down to field.
346 super.onKeypress(keypress
);
349 if (keypress
.equals(kbEnter
) || keypress
.equals(kbF2
)) {
350 // Enter or F2 starts editing.
351 fieldText
= field
.getText();
353 field
.setEnabled(true);
359 // --------------------------------------------------------------------
360 // TWidget ------------------------------------------------------------
361 // --------------------------------------------------------------------
368 TTableWidget table
= (TTableWidget
) getParent();
370 if (isAbsoluteActive()) {
372 field
.setActiveColorKey("tfield.active");
373 field
.setInactiveColorKey("tfield.inactive");
375 field
.setActiveColorKey("ttable.selected");
376 field
.setInactiveColorKey("ttable.selected");
378 } else if (((table
.selectedColumn
== column
)
379 && ((table
.selectedRow
== row
)
380 || (table
.highlightColumn
== true)))
381 || ((table
.selectedRow
== row
)
382 && ((table
.selectedColumn
== column
)
383 || (table
.highlightRow
== true)))
385 field
.setActiveColorKey("ttable.active");
386 field
.setInactiveColorKey("ttable.active");
388 field
.setActiveColorKey("ttable.active");
389 field
.setInactiveColorKey("ttable.inactive");
392 assert (isVisible() == true);
397 // --------------------------------------------------------------------
398 // TTable.Cell --------------------------------------------------------
399 // --------------------------------------------------------------------
406 public final String
getText() {
407 return field
.getText();
413 * @param text the new field text
415 public void setText(final String text
) {
421 // ------------------------------------------------------------------------
422 // Constructors -----------------------------------------------------------
423 // ------------------------------------------------------------------------
426 * Public constructor.
428 * @param parent parent widget
429 * @param x column relative to parent
430 * @param y row relative to parent
431 * @param width width of widget
432 * @param height height of widget
434 public TTableWidget(final TWidget parent
, final int x
, final int y
,
435 final int width
, final int height
) {
437 super(parent
, x
, y
, width
, height
);
439 // Initialize the starting row and column.
440 rows
.add(new Row(0));
441 columns
.add(new Column(0));
443 // Place a grid of cells that fit in this space.
445 for (int i
= 0; i
< height
; i
+= rows
.get(0).height
) {
447 for (int j
= 0; j
< width
; j
+= columns
.get(0).width
) {
448 Cell cell
= new Cell(this, j
, i
, columns
.get(0).width
,
449 rows
.get(0).height
, column
, row
);
451 cell
.setText("" + row
+ " " + column
);
452 rows
.get(row
).add(cell
);
453 columns
.get(column
).add(cell
);
454 if ((i
== 0) && (j
+ columns
.get(0).width
< width
)) {
455 columns
.add(new Column(column
+ 1));
459 if (i
+ rows
.get(0).height
< height
) {
460 rows
.add(new Row(row
+ 1));
464 activate(columns
.get(selectedColumn
).get(selectedRow
));
468 // Set the menu to match the flags.
469 getApplication().getMenuItem(TMenu
.MID_TABLE_VIEW_ROW_LABELS
).
470 setChecked(showRowLabels
);
471 getApplication().getMenuItem(TMenu
.MID_TABLE_VIEW_COLUMN_LABELS
).
472 setChecked(showColumnLabels
);
473 getApplication().getMenuItem(TMenu
.MID_TABLE_VIEW_HIGHLIGHT_ROW
).
474 setChecked(highlightRow
);
475 getApplication().getMenuItem(TMenu
.MID_TABLE_VIEW_HIGHLIGHT_COLUMN
).
476 setChecked(highlightColumn
);
481 // ------------------------------------------------------------------------
482 // Event handlers ---------------------------------------------------------
483 // ------------------------------------------------------------------------
488 * @param keypress keystroke event
491 public void onKeypress(final TKeypressEvent keypress
) {
492 if (keypress
.equals(kbTab
)
493 || keypress
.equals(kbShiftTab
)
495 // Squash tab and back-tab. They don't make sense in the TTable
500 // If editing, pass to that cell and do nothing else.
501 if (getSelectedCell().isEditing
) {
502 super.onKeypress(keypress
);
506 if (keypress
.equals(kbLeft
)) {
507 if (selectedColumn
> 0) {
510 activate(columns
.get(selectedColumn
).get(selectedRow
));
511 } else if (keypress
.equals(kbRight
)) {
512 if (selectedColumn
< columns
.size() - 1) {
515 activate(columns
.get(selectedColumn
).get(selectedRow
));
516 } else if (keypress
.equals(kbUp
)) {
517 if (selectedRow
> 0) {
520 activate(columns
.get(selectedColumn
).get(selectedRow
));
521 } else if (keypress
.equals(kbDown
)) {
522 if (selectedRow
< rows
.size() - 1) {
525 activate(columns
.get(selectedColumn
).get(selectedRow
));
526 } else if (keypress
.equals(kbHome
)) {
528 activate(columns
.get(selectedColumn
).get(selectedRow
));
529 } else if (keypress
.equals(kbEnd
)) {
530 selectedColumn
= columns
.size() - 1;
531 activate(columns
.get(selectedColumn
).get(selectedRow
));
532 } else if (keypress
.equals(kbPgUp
)) {
534 } else if (keypress
.equals(kbPgDn
)) {
536 } else if (keypress
.equals(kbCtrlHome
)) {
538 } else if (keypress
.equals(kbCtrlEnd
)) {
542 super.onKeypress(keypress
);
545 // We may have scrolled off screen. Reset positions as needed to
546 // make the newly selected cell visible.
551 * Handle widget resize events.
553 * @param event resize event
556 public void onResize(final TResizeEvent event
) {
557 super.onResize(event
);
563 * Handle posted menu events.
565 * @param menu menu event
568 public void onMenu(final TMenuEvent menu
) {
569 switch (menu
.getId()) {
570 case TMenu
.MID_TABLE_VIEW_ROW_LABELS
:
571 showRowLabels
= getApplication().getMenuItem(menu
.getId()).getChecked();
573 case TMenu
.MID_TABLE_VIEW_COLUMN_LABELS
:
574 showColumnLabels
= getApplication().getMenuItem(menu
.getId()).getChecked();
576 case TMenu
.MID_TABLE_VIEW_HIGHLIGHT_ROW
:
577 highlightRow
= getApplication().getMenuItem(menu
.getId()).getChecked();
579 case TMenu
.MID_TABLE_VIEW_HIGHLIGHT_COLUMN
:
580 highlightColumn
= getApplication().getMenuItem(menu
.getId()).getChecked();
582 case TMenu
.MID_TABLE_BORDER_NONE
:
583 case TMenu
.MID_TABLE_BORDER_ALL
:
584 case TMenu
.MID_TABLE_BORDER_RIGHT
:
585 case TMenu
.MID_TABLE_BORDER_LEFT
:
586 case TMenu
.MID_TABLE_BORDER_TOP
:
587 case TMenu
.MID_TABLE_BORDER_BOTTOM
:
588 case TMenu
.MID_TABLE_BORDER_DOUBLE_BOTTOM
:
589 case TMenu
.MID_TABLE_BORDER_THICK_BOTTOM
:
590 case TMenu
.MID_TABLE_DELETE_LEFT
:
591 case TMenu
.MID_TABLE_DELETE_UP
:
592 case TMenu
.MID_TABLE_DELETE_ROW
:
593 case TMenu
.MID_TABLE_DELETE_COLUMN
:
594 case TMenu
.MID_TABLE_INSERT_LEFT
:
595 case TMenu
.MID_TABLE_INSERT_RIGHT
:
596 case TMenu
.MID_TABLE_INSERT_ABOVE
:
597 case TMenu
.MID_TABLE_INSERT_BELOW
:
599 case TMenu
.MID_TABLE_COLUMN_NARROW
:
600 columns
.get(selectedColumn
).width
--;
601 for (Cell cell
: getSelectedColumn().cells
) {
602 cell
.setWidth(columns
.get(selectedColumn
).width
);
603 cell
.field
.setWidth(columns
.get(selectedColumn
).width
- 1);
605 for (int i
= selectedColumn
+ 1; i
< columns
.size(); i
++) {
606 for (Cell cell
: columns
.get(i
).cells
) {
607 cell
.setX(cell
.getX() - 1);
612 case TMenu
.MID_TABLE_COLUMN_WIDEN
:
613 columns
.get(selectedColumn
).width
++;
614 for (Cell cell
: getSelectedColumn().cells
) {
615 cell
.setWidth(columns
.get(selectedColumn
).width
);
616 cell
.field
.setWidth(columns
.get(selectedColumn
).width
- 1);
618 for (int i
= selectedColumn
+ 1; i
< columns
.size(); i
++) {
619 for (Cell cell
: columns
.get(i
).cells
) {
620 cell
.setX(cell
.getX() + 1);
625 case TMenu
.MID_TABLE_FILE_SAVE_CSV
:
626 case TMenu
.MID_TABLE_FILE_SAVE_TEXT
:
635 // ------------------------------------------------------------------------
636 // TWidget ----------------------------------------------------------------
637 // ------------------------------------------------------------------------
640 * Draw the table row/column labels, and borders.
644 CellAttributes labelColor
= getTheme().getColor("ttable.label");
645 CellAttributes labelColorSelected
= getTheme().getColor("ttable.label.selected");
646 CellAttributes borderColor
= getTheme().getColor("ttable.border");
649 if (showColumnLabels
== true) {
650 for (int i
= left
; i
< columns
.size(); i
++) {
651 if (columns
.get(i
).get(top
).isVisible() == false) {
654 putStringXY(columns
.get(i
).get(top
).getX(), 0,
655 String
.format(" %-6s ", columns
.get(i
).label
),
656 (i
== selectedColumn ? labelColorSelected
: labelColor
));
661 if (showRowLabels
== true) {
662 for (int i
= top
; i
< rows
.size(); i
++) {
663 if (rows
.get(i
).get(left
).isVisible() == false) {
666 putStringXY(0, rows
.get(i
).get(left
).getY(),
667 String
.format(" %-6s ", rows
.get(i
).label
),
668 (i
== selectedRow ? labelColorSelected
: labelColor
));
672 // Now draw the window borders.
676 // ------------------------------------------------------------------------
677 // TTable -----------------------------------------------------------------
678 // ------------------------------------------------------------------------
681 * Get the currently-selected cell.
683 * @return the selected cell
685 public Cell
getSelectedCell() {
686 assert (rows
.get(selectedRow
) != null);
687 assert (rows
.get(selectedRow
).get(selectedColumn
) != null);
688 assert (columns
.get(selectedColumn
) != null);
689 assert (columns
.get(selectedColumn
).get(selectedRow
) != null);
690 assert (rows
.get(selectedRow
).get(selectedColumn
) ==
691 columns
.get(selectedColumn
).get(selectedRow
));
693 return (columns
.get(selectedColumn
).get(selectedRow
));
697 * Get the currently-selected column.
699 * @return the selected column
701 public Column
getSelectedColumn() {
702 assert (selectedColumn
>= 0);
703 assert (columns
.size() > selectedColumn
);
704 assert (columns
.get(selectedColumn
) != null);
705 return columns
.get(selectedColumn
);
709 * Get the currently-selected row.
711 * @return the selected row
713 public Row
getSelectedRow() {
714 assert (selectedRow
>= 0);
715 assert (rows
.size() > selectedRow
);
716 assert (rows
.get(selectedRow
) != null);
717 return rows
.get(selectedRow
);
721 * Get the full horizontal width of this table.
723 * @return the width required to render the entire table
725 public int getMaximumWidth() {
727 if (showRowLabels
== true) {
728 // For now, all row labels are 8 cells wide. TODO: make this
732 for (Cell cell
: getSelectedRow().cells
) {
733 totalWidth
+= cell
.getWidth() + 1;
739 * Get the full vertical height of this table.
741 * @return the height required to render the entire table
743 public int getMaximumHeight() {
745 if (showColumnLabels
== true) {
746 // For now, all column labels are 1 cell tall. TODO: make this
750 for (Cell cell
: getSelectedColumn().cells
) {
751 totalHeight
+= cell
.getHeight();
752 // TODO: handle top/bottom borders.
758 * Align the grid so that the selected cell is fully visible.
760 private void alignGrid() {
763 * We start by assuming that all cells are visible, and then mark as
764 * invisible those that are outside the viewable area.
766 for (int x
= 0; x
< columns
.size(); x
++) {
767 for (int y
= 0; y
< rows
.size(); y
++) {
768 rows
.get(y
).cells
.get(x
).setVisible(true);
772 // Adjust X locations to be visible -----------------------------------
774 // Determine if we need to shift left or right.
776 if (showRowLabels
== true) {
777 // For now, all row labels are 8 cells wide. TODO: make this
781 Row row
= getSelectedRow();
782 Cell selectedColumnCell
= null;
783 for (int i
= 0; i
< row
.cells
.size(); i
++) {
784 if (i
== selectedColumn
) {
785 selectedColumnCell
= row
.cells
.get(i
);
788 leftCellX
+= row
.cells
.get(i
).getWidth() + 1;
790 // There should always be a selected column.
791 assert (selectedColumnCell
!= null);
793 int excessWidth
= leftCellX
+ selectedColumnCell
.getWidth() + 1 - getWidth();
794 if (excessWidth
> 0) {
795 leftCellX
-= excessWidth
;
798 if (showRowLabels
== true) {
806 * leftCellX now contains the basic left offset necessary to draw the
807 * cells such that the selected cell (column) is fully visible within
808 * this widget's given width. Or, if the widget is too narrow to
809 * display the full cell, leftCellX is 0 or 8.
811 * Now reset all of the X positions of the other cells so that the
812 * selected cell X is leftCellX.
814 for (int y
= 0; y
< rows
.size(); y
++) {
815 // All cells to the left of selected cell.
816 int newCellX
= leftCellX
;
817 left
= selectedColumn
;
818 for (int x
= selectedColumn
- 1; x
>= 0; x
--) {
819 newCellX
-= rows
.get(y
).cells
.get(x
).getWidth() + 1;
820 if (newCellX
- rows
.get(y
).cells
.get(x
).getWidth() + 1 >= 0) {
821 rows
.get(y
).cells
.get(x
).setVisible(true);
822 rows
.get(y
).cells
.get(x
).setX(newCellX
);
825 // This cell won't be visible.
826 rows
.get(y
).cells
.get(x
).setVisible(false);
831 rows
.get(y
).cells
.get(selectedColumn
).setX(leftCellX
);
832 assert (rows
.get(y
).cells
.get(selectedColumn
).isVisible());
834 // All cells to the right of selected cell.
835 newCellX
= leftCellX
+ selectedColumnCell
.getWidth() + 1;
836 for (int x
= selectedColumn
+ 1; x
< columns
.size(); x
++) {
837 if (newCellX
<= getWidth()) {
838 rows
.get(y
).cells
.get(x
).setVisible(true);
839 rows
.get(y
).cells
.get(x
).setX(newCellX
);
841 // This cell won't be visible.
842 rows
.get(y
).cells
.get(x
).setVisible(false);
844 newCellX
+= rows
.get(y
).cells
.get(x
).getWidth() + 1;
848 // Adjust Y locations to be visible -----------------------------------
849 // The same logic as above, but applied to the column Y.
851 // Determine if we need to shift up or down.
853 if (showColumnLabels
== true) {
854 // For now, all column labels are 1 cell high. TODO: make this
858 Column column
= getSelectedColumn();
859 Cell selectedRowCell
= null;
860 for (int i
= 0; i
< column
.cells
.size(); i
++) {
861 if (i
== selectedRow
) {
862 selectedRowCell
= column
.cells
.get(i
);
865 topCellY
+= column
.cells
.get(i
).getHeight();
866 // TODO: if a border is selected, add 1 to topCellY.
868 // There should always be a selected row.
869 assert (selectedRowCell
!= null);
871 int excessHeight
= topCellY
+ selectedRowCell
.getHeight() - getHeight() - 1;
872 if (showColumnLabels
== true) {
875 if (excessHeight
> 0) {
876 topCellY
-= excessHeight
;
879 if (showColumnLabels
== true) {
887 * topCellY now contains the basic top offset necessary to draw the
888 * cells such that the selected cell (row) is fully visible within
889 * this widget's given height. Or, if the widget is too short to
890 * display the full cell, topCellY is 0 or 1.
892 * Now reset all of the Y positions of the other cells so that the
893 * selected cell Y is topCellY.
895 for (int x
= 0; x
< columns
.size(); x
++) {
897 if (columns
.get(x
).get(0).isVisible() == false) {
898 // This column won't be visible as determined by the checks
899 // above, just continue to the next.
903 // All cells above the selected cell.
904 int newCellY
= topCellY
;
906 for (int y
= selectedRow
- 1; y
>= 0; y
--) {
907 newCellY
-= rows
.get(y
).cells
.get(x
).getHeight();
908 if (newCellY
>= (showColumnLabels
== true ?
1 : 0)) {
909 rows
.get(y
).cells
.get(x
).setVisible(true);
910 rows
.get(y
).cells
.get(x
).setY(newCellY
);
913 // This cell won't be visible.
914 rows
.get(y
).cells
.get(x
).setVisible(false);
919 columns
.get(x
).cells
.get(selectedRow
).setY(topCellY
);
921 // All cells below the selected cell.
922 newCellY
= topCellY
+ selectedRowCell
.getHeight();
923 for (int y
= selectedRow
+ 1; y
< rows
.size(); y
++) {
924 if (newCellY
<= getHeight()) {
925 rows
.get(y
).cells
.get(x
).setVisible(true);
926 rows
.get(y
).cells
.get(x
).setY(newCellY
);
928 // This cell won't be visible.
929 rows
.get(y
).cells
.get(x
).setVisible(false);
931 newCellY
+= rows
.get(y
).cells
.get(x
).getHeight();