From e3af41ba1431daf8a132d5c182c6055c0af5c393 Mon Sep 17 00:00:00 2001 From: TunarJamalov Date: Thu, 30 Jul 2026 21:50:27 +0400 Subject: [PATCH] feat: Add PojoTableModel for POJO-based row management (Issue #658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce PojoTableModel extending TableModel to allow rows to be represented as Plain Old Java Objects (POJOs), following the Java Swing MVC pattern more closely. New capabilities: - addRow(R pojo) — add a POJO-backed row - insertRow(int, R) — insert a POJO at a specific index - getPojoRow(int) — retrieve the backing POJO for a row - getPojoRows() — get all POJOs as an unmodifiable list - setPojoRow(int, R) — update a row's backing POJO - removePojoRow(R) — remove a row by its POJO reference - indexOfPojo(R) — find the row index of a POJO Full backward compatibility is maintained: all existing TableModel methods continue to work as before. Legacy rows (added via the Collection/varargs API) are tracked with null placeholders so that POJO and non-POJO rows can be mixed freely. 30 new unit tests added covering all new methods, edge cases, backward compatibility and listener integration. Fixes #658 --- .../lanterna/gui2/table/PojoTableModel.java | 293 +++++++++++++ .../gui2/table/PojoTableModelTest.java | 405 ++++++++++++++++++ 2 files changed, 698 insertions(+) create mode 100644 src/main/java/com/googlecode/lanterna/gui2/table/PojoTableModel.java create mode 100644 src/test/java/com/googlecode/lanterna/gui2/table/PojoTableModelTest.java diff --git a/src/main/java/com/googlecode/lanterna/gui2/table/PojoTableModel.java b/src/main/java/com/googlecode/lanterna/gui2/table/PojoTableModel.java new file mode 100644 index 000000000..acda23d0a --- /dev/null +++ b/src/main/java/com/googlecode/lanterna/gui2/table/PojoTableModel.java @@ -0,0 +1,293 @@ +/* + * This file is part of lanterna (https://github.com/mabe02/lanterna). + * + * lanterna is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + * Copyright (C) 2010-2024 Martin Berglund + */ +package com.googlecode.lanterna.gui2.table; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * A {@code PojoTableModel} is an extension of {@link TableModel} that allows rows to be represented + * as Plain Old Java Objects (POJOs), following the Java Swing MVC (Model-View-Controller) pattern + * more closely. + * + *

Instead of managing individual column values separately, you can work with your own domain + * objects directly. A {@link Function} (row mapper) is provided at construction time to extract + * the individual column values from each POJO when the table needs to render them.

+ * + *

All existing {@link TableModel} methods (e.g. {@code addRow(V... values)}, + * {@code getRow(int index)}) remain fully functional for backward compatibility. The POJO-based + * rows added via {@link #addRow(Object)} are tracked separately and can be retrieved via + * {@link #getPojoRow(int)} or {@link #getPojoRows()}.

+ * + *

Example usage:

+ *
{@code
+ * PojoTableModel model = new PojoTableModel<>(
+ *     person -> Arrays.asList(person.getName(), person.getSurname(), String.valueOf(person.getAge())),
+ *     "Name", "Surname", "Age"
+ * );
+ *
+ * model.addRow(new Person("Tunar", "Bayramov", 25));
+ * model.addRow(new Person("Martin", "Berglund", 40));
+ *
+ * Person first = model.getPojoRow(0); // returns Person("Tunar", ...)
+ * List all = model.getPojoRows(); // returns all POJOs
+ * }
+ * + * @param The type of the POJO (row object) stored in this model + * @param The type of the individual cell values used for rendering (e.g. {@code String}) + * @author Contribution for Issue #658 + */ +public class PojoTableModel extends TableModel { + + /** + * Keeps a POJO reference (or {@code null} for legacy rows) for every row stored in the + * parent {@link TableModel}. The two lists are always kept in sync: + * {@code pojoRows.size() == getRowCount()} at all times. + */ + private final List pojoRows; + + /** + * Converts a POJO of type {@code R} into a list of cell values of type {@code V}. + * The list must have the same number of elements as there are columns in this model. + */ + private final Function> rowMapper; + + /** + * Flag set to {@code true} for the duration of a POJO-initiated {@code insertRow} call so + * that the overridden {@link #insertRow(int, java.util.Collection)} knows NOT to add an + * extra {@code null} into {@link #pojoRows} (the POJO-path methods handle that themselves). + */ + private boolean insertingPojo = false; + + /** + * Creates a new {@code PojoTableModel} with the specified column labels and a row mapper + * function that knows how to extract cell values from a POJO. + * + * @param rowMapper A function that maps a POJO of type {@code R} to a {@link List} of + * cell values. The list size must match the number of column labels + * provided. Must not be {@code null}. + * @param columnLabels Variable-length array of column header labels. At least one label + * must be provided. + * @throws NullPointerException if {@code rowMapper} is {@code null} + * @throws IllegalArgumentException if no column labels are provided + */ + public PojoTableModel(Function> rowMapper, String... columnLabels) { + this(rowMapper, Arrays.asList(columnLabels)); + } + + /** + * Creates a new {@code PojoTableModel} with the specified column labels and a row mapper + * function that knows how to extract cell values from a POJO. + * + * @param rowMapper A function that maps a POJO of type {@code R} to a {@link List} of + * cell values. The list size must match the number of column labels + * provided. Must not be {@code null}. + * @param columnLabels List of column header labels. At least one label must be provided. + * @throws NullPointerException if {@code rowMapper} is {@code null} + * @throws IllegalArgumentException if no column labels are provided + */ + public PojoTableModel(Function> rowMapper, List columnLabels) { + super(columnLabels); + this.rowMapper = Objects.requireNonNull(rowMapper, "rowMapper must not be null"); + this.pojoRows = new ArrayList<>(); + } + + /** + * Adds a new row to the table model using a POJO as the data source. The row mapper + * provided at construction time is used to extract the individual column values from + * the POJO. + * + *

The POJO itself is stored internally and can be retrieved later via + * {@link #getPojoRow(int)} or {@link #getPojoRows()}.

+ * + * @param pojo The POJO to add as a new row. May be {@code null} if your domain requires it, + * but note that the row mapper will be called with {@code null} in that case. + * @return Itself, for method chaining + */ + public synchronized PojoTableModel addRow(R pojo) { + List columnValues = rowMapper.apply(pojo); + insertingPojo = true; + try { + super.addRow(columnValues); + } finally { + insertingPojo = false; + } + pojoRows.add(pojo); + return this; + } + + /** + * Inserts a new POJO-backed row into the table model at a specific index. The row mapper + * provided at construction time is used to extract the individual column values. + * + * @param index Index at which to insert the new row. {@code 0} means first, and + * {@link #getRowCount()} appends at the end. + * @param pojo The POJO to insert as a new row. + * @return Itself, for method chaining + */ + public synchronized PojoTableModel insertRow(int index, R pojo) { + List columnValues = rowMapper.apply(pojo); + insertingPojo = true; + try { + super.insertRow(index, columnValues); + } finally { + insertingPojo = false; + } + pojoRows.add(index, pojo); + return this; + } + + /** + * Override the Collection-based {@code insertRow} — the single choke-point for ALL + * {@link TableModel#addRow} variants — so that legacy (non-POJO) calls also keep + * {@link #pojoRows} in sync by inserting a {@code null} placeholder at the same index. + * + *

When a POJO-path method is executing it sets {@link #insertingPojo} to {@code true} + * before delegating here, so we know NOT to add a second entry.

+ * + * @param index Index at which to insert the new row + * @param values Cell values for the new row + * @return Itself + */ + @Override + public synchronized TableModel insertRow(int index, java.util.Collection values) { + super.insertRow(index, values); + if (!insertingPojo) { + // Legacy call — insert a null placeholder so pojoRows stays in sync + pojoRows.add(index, null); + } + return this; + } + + /** + * Removes the row at the specified index from the model. If the row was added via + * {@link #addRow(Object)}, the corresponding POJO is also removed from the internal + * POJO list. + * + * @param index Index of the row to remove + * @return Itself, for method chaining + */ + @Override + public synchronized PojoTableModel removeRow(int index) { + super.removeRow(index); + // pojoRows is always the same size as the parent row list, so the index is always valid + pojoRows.remove(index); + return this; + } + + /** + * Removes all rows from the model, including any stored POJO references. + * + * @return Itself, for method chaining + */ + @Override + public synchronized PojoTableModel clear() { + super.clear(); + pojoRows.clear(); + return this; + } + + /** + * Returns the POJO stored at the given row index, or {@code null} if the row at that index + * was added via the legacy {@link TableModel#addRow(Object[])} / {@link TableModel#addRow(java.util.Collection)} + * API (i.e. not POJO-backed). + * + * @param index The row index to retrieve + * @return The POJO at the given index, or {@code null} for non-POJO rows + * @throws IndexOutOfBoundsException if {@code index} is out of range + */ + public synchronized R getPojoRow(int index) { + if (index < 0 || index >= getRowCount()) { + throw new IndexOutOfBoundsException( + "Row index " + index + " is out of bounds (row count: " + getRowCount() + ")"); + } + if (index < pojoRows.size()) { + return pojoRows.get(index); + } + return null; + } + + /** + * Returns an unmodifiable view of all POJOs stored in this model, in insertion order. + * Rows that were added via the legacy API are represented as {@code null} entries in the + * returned list. + * + * @return An unmodifiable list of POJOs (may contain {@code null} for non-POJO rows) + */ + public synchronized List getPojoRows() { + return Collections.unmodifiableList(new ArrayList<>(pojoRows)); + } + + /** + * Returns the index of the first occurrence of the given POJO in this model, or {@code -1} + * if it is not found. Uses {@link Object#equals(Object)} for comparison. + * + * @param pojo The POJO to search for + * @return The row index of the first matching POJO, or {@code -1} if not found + */ + public synchronized int indexOfPojo(R pojo) { + return pojoRows.indexOf(pojo); + } + + /** + * Removes the first row whose backing POJO equals the given object (as determined by + * {@link Object#equals(Object)}). Does nothing if the POJO is not found. + * + * @param pojo The POJO whose row should be removed + * @return Itself, for method chaining + */ + public synchronized PojoTableModel removePojoRow(R pojo) { + int index = indexOfPojo(pojo); + if (index >= 0) { + removeRow(index); + } + return this; + } + + /** + * Updates the row at the given index by replacing its backing POJO and re-applying the row + * mapper to refresh the cell values. This is the POJO equivalent of calling + * {@link TableModel#setCell(int, int, Object)} for every column. + * + * @param index The row index to update + * @param pojo The new POJO to store at this row + * @return Itself, for method chaining + * @throws IndexOutOfBoundsException if {@code index} is out of range + */ + public synchronized PojoTableModel setPojoRow(int index, R pojo) { + if (index < 0 || index >= getRowCount()) { + throw new IndexOutOfBoundsException( + "Row index " + index + " is out of bounds (row count: " + getRowCount() + ")"); + } + List columnValues = rowMapper.apply(pojo); + for (int col = 0; col < columnValues.size() && col < getColumnCount(); col++) { + setCell(col, index, columnValues.get(col)); + } + // Extend pojoRows list with nulls if needed (for rows added via legacy API) + while (pojoRows.size() <= index) { + pojoRows.add(null); + } + pojoRows.set(index, pojo); + return this; + } +} diff --git a/src/test/java/com/googlecode/lanterna/gui2/table/PojoTableModelTest.java b/src/test/java/com/googlecode/lanterna/gui2/table/PojoTableModelTest.java new file mode 100644 index 000000000..40173fdf2 --- /dev/null +++ b/src/test/java/com/googlecode/lanterna/gui2/table/PojoTableModelTest.java @@ -0,0 +1,405 @@ +/* + * This file is part of lanterna (https://github.com/mabe02/lanterna). + * + * lanterna is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + * Copyright (C) 2010-2024 Martin Berglund + */ +package com.googlecode.lanterna.gui2.table; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link PojoTableModel}, verifying that POJO-based row management works + * correctly alongside the existing {@link TableModel} API (backward compatibility). + */ +public class PojoTableModelTest { + + // ------------------------------------------------------------------- + // Simple POJO used across tests + // ------------------------------------------------------------------- + + /** + * A minimal domain object representing a person, used as the row type in these tests. + */ + static class Person { + final String name; + final String surname; + final int age; + + Person(String name, String surname, int age) { + this.name = name; + this.surname = surname; + this.age = age; + } + + /** Column values returned by the row mapper: Name, Surname, Age */ + List toColumns() { + return Arrays.asList(name, surname, String.valueOf(age)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Person)) return false; + Person p = (Person) o; + return age == p.age && Objects.equals(name, p.name) && Objects.equals(surname, p.surname); + } + + @Override + public int hashCode() { + return Objects.hash(name, surname, age); + } + + @Override + public String toString() { + return name + " " + surname + " (" + age + ")"; + } + } + + // ------------------------------------------------------------------- + // Test setup + // ------------------------------------------------------------------- + + private PojoTableModel model; + private final Person tunar = new Person("Tunar", "Bayramov", 25); + private final Person martin = new Person("Martin", "Berglund", 40); + private final Person alice = new Person("Alice", "Wonderland", 30); + + @Before + public void setUp() { + model = new PojoTableModel<>(Person::toColumns, "Name", "Surname", "Age"); + } + + // ------------------------------------------------------------------- + // Construction + // ------------------------------------------------------------------- + + @Test + public void testConstructorWithVarargs() { + PojoTableModel m = + new PojoTableModel<>(Person::toColumns, "Name", "Surname", "Age"); + assertEquals(3, m.getColumnCount()); + assertEquals(0, m.getRowCount()); + } + + @Test + public void testConstructorWithList() { + List labels = Arrays.asList("Name", "Surname", "Age"); + PojoTableModel m = new PojoTableModel<>(Person::toColumns, labels); + assertEquals(3, m.getColumnCount()); + assertEquals(0, m.getRowCount()); + } + + @Test(expected = NullPointerException.class) + public void testConstructorNullRowMapperThrows() { + new PojoTableModel<>(null, "Name"); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructorNoColumnsThrows() { + new PojoTableModel<>(Person::toColumns); + } + + // ------------------------------------------------------------------- + // addRow(R pojo) + // ------------------------------------------------------------------- + + @Test + public void testAddRowIncrementsRowCount() { + assertEquals(0, model.getRowCount()); + model.addRow(tunar); + assertEquals(1, model.getRowCount()); + model.addRow(martin); + assertEquals(2, model.getRowCount()); + } + + @Test + public void testAddRowPopulatesCellValues() { + model.addRow(tunar); + // Verify that the parent TableModel cells reflect the POJO's fields + assertEquals("Tunar", model.getCell(0, 0)); + assertEquals("Bayramov", model.getCell(1, 0)); + assertEquals("25", model.getCell(2, 0)); + } + + @Test + public void testAddMultipleRowsPreservesOrder() { + model.addRow(tunar); + model.addRow(martin); + model.addRow(alice); + + assertEquals(tunar, model.getPojoRow(0)); + assertEquals(martin, model.getPojoRow(1)); + assertEquals(alice, model.getPojoRow(2)); + } + + // ------------------------------------------------------------------- + // getPojoRow(int) + // ------------------------------------------------------------------- + + @Test + public void testGetPojoRowReturnsSamePojo() { + model.addRow(tunar); + assertSame(tunar, model.getPojoRow(0)); + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testGetPojoRowOutOfBoundsThrows() { + model.addRow(tunar); + model.getPojoRow(1); // only row 0 exists + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testGetPojoRowNegativeIndexThrows() { + model.getPojoRow(-1); + } + + // ------------------------------------------------------------------- + // getPojoRows() + // ------------------------------------------------------------------- + + @Test + public void testGetPojoRowsReturnsAllInOrder() { + model.addRow(tunar); + model.addRow(martin); + List all = model.getPojoRows(); + assertEquals(2, all.size()); + assertEquals(tunar, all.get(0)); + assertEquals(martin, all.get(1)); + } + + @Test + public void testGetPojoRowsReturnsUnmodifiableCopy() { + model.addRow(tunar); + List snapshot = model.getPojoRows(); + try { + snapshot.add(martin); + fail("Should have thrown UnsupportedOperationException"); + } catch (UnsupportedOperationException expected) { + // correct + } + // Adding to snapshot must not affect model + assertEquals(1, model.getRowCount()); + } + + @Test + public void testGetPojoRowsIsEmptyInitially() { + assertTrue(model.getPojoRows().isEmpty()); + } + + // ------------------------------------------------------------------- + // insertRow(int, R) + // ------------------------------------------------------------------- + + @Test + public void testInsertRowAtBeginning() { + model.addRow(martin); + model.insertRow(0, tunar); + + assertEquals(2, model.getRowCount()); + assertEquals(tunar, model.getPojoRow(0)); + assertEquals(martin, model.getPojoRow(1)); + assertEquals("Tunar", model.getCell(0, 0)); + } + + @Test + public void testInsertRowInMiddle() { + model.addRow(tunar); + model.addRow(alice); + model.insertRow(1, martin); + + assertEquals(3, model.getRowCount()); + assertEquals(tunar, model.getPojoRow(0)); + assertEquals(martin, model.getPojoRow(1)); + assertEquals(alice, model.getPojoRow(2)); + } + + // ------------------------------------------------------------------- + // removeRow(int) + // ------------------------------------------------------------------- + + @Test + public void testRemoveRowDecrementsRowCount() { + model.addRow(tunar); + model.addRow(martin); + model.removeRow(0); + assertEquals(1, model.getRowCount()); + assertEquals(martin, model.getPojoRow(0)); + } + + @Test + public void testRemoveRowUpdatesPojoList() { + model.addRow(tunar); + model.addRow(martin); + model.addRow(alice); + model.removeRow(1); // remove martin + + assertEquals(2, model.getRowCount()); + assertEquals(tunar, model.getPojoRow(0)); + assertEquals(alice, model.getPojoRow(1)); + } + + // ------------------------------------------------------------------- + // removePojoRow(R) + // ------------------------------------------------------------------- + + @Test + public void testRemovePojoRowByObject() { + model.addRow(tunar); + model.addRow(martin); + model.removePojoRow(tunar); + + assertEquals(1, model.getRowCount()); + assertEquals(martin, model.getPojoRow(0)); + } + + @Test + public void testRemovePojoRowNotFoundDoesNothing() { + model.addRow(tunar); + model.removePojoRow(martin); // martin not in model + assertEquals(1, model.getRowCount()); + } + + // ------------------------------------------------------------------- + // indexOfPojo(R) + // ------------------------------------------------------------------- + + @Test + public void testIndexOfPojoFound() { + model.addRow(tunar); + model.addRow(martin); + assertEquals(1, model.indexOfPojo(martin)); + } + + @Test + public void testIndexOfPojoNotFound() { + model.addRow(tunar); + assertEquals(-1, model.indexOfPojo(alice)); + } + + // ------------------------------------------------------------------- + // setPojoRow(int, R) + // ------------------------------------------------------------------- + + @Test + public void testSetPojoRowUpdatesCellsAndPojo() { + model.addRow(tunar); + model.setPojoRow(0, martin); + + assertEquals(martin, model.getPojoRow(0)); + assertEquals("Martin", model.getCell(0, 0)); + assertEquals("Berglund", model.getCell(1, 0)); + assertEquals("40", model.getCell(2, 0)); + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testSetPojoRowOutOfBoundsThrows() { + model.setPojoRow(0, tunar); // model is empty + } + + // ------------------------------------------------------------------- + // clear() + // ------------------------------------------------------------------- + + @Test + public void testClearRemovesAllRowsAndPojos() { + model.addRow(tunar); + model.addRow(martin); + model.clear(); + + assertEquals(0, model.getRowCount()); + assertTrue(model.getPojoRows().isEmpty()); + } + + // ------------------------------------------------------------------- + // Backward compatibility — legacy TableModel API still works + // ------------------------------------------------------------------- + + @Test + public void testLegacyAddRowVarargs() { + // Adding rows via the inherited TableModel API should still work + model.addRow("Legacy", "User", "99"); + assertEquals(1, model.getRowCount()); + assertEquals("Legacy", model.getCell(0, 0)); + // getPojoRow returns null for non-POJO rows + assertNull(model.getPojoRow(0)); + } + + @Test + public void testMixedPojoAndLegacyRows() { + model.addRow(tunar); // POJO row at index 0 + model.addRow("Legacy", "User", "99"); // legacy row at index 1 + model.addRow(alice); // POJO row at index 2 + + assertEquals(3, model.getRowCount()); + assertEquals(tunar, model.getPojoRow(0)); + assertNull(model.getPojoRow(1)); // legacy row + assertEquals(alice, model.getPojoRow(2)); + + // Cell values of the POJO rows are correct + assertEquals("Tunar", model.getCell(0, 0)); + assertEquals("Legacy", model.getCell(0, 1)); + assertEquals("Alice", model.getCell(0, 2)); + } + + @Test + public void testGetRowStillReturnsListForPojoRow() { + model.addRow(tunar); + List row = model.getRow(0); + assertEquals(Arrays.asList("Tunar", "Bayramov", "25"), row); + } + + // ------------------------------------------------------------------- + // Listener integration (inherited from TableModel) + // ------------------------------------------------------------------- + + @Test + public void testListenerFiredOnAddRow() { + final int[] addedIndex = {-1}; + model.addListener(new TableModel.Listener() { + @Override public void onRowAdded(TableModel m, int index) { addedIndex[0] = index; } + @Override public void onRowRemoved(TableModel m, int index, List r) {} + @Override public void onColumnAdded(TableModel m, int index) {} + @Override public void onColumnRemoved(TableModel m, int index, String h, List c) {} + @Override public void onCellChanged(TableModel m, int row, int col, String o, String n) {} + }); + + model.addRow(tunar); + assertEquals(0, addedIndex[0]); + } + + @Test + public void testListenerFiredOnRemoveRow() { + model.addRow(tunar); + final int[] removedIndex = {-1}; + model.addListener(new TableModel.Listener() { + @Override public void onRowAdded(TableModel m, int index) {} + @Override public void onRowRemoved(TableModel m, int index, List r) { removedIndex[0] = index; } + @Override public void onColumnAdded(TableModel m, int index) {} + @Override public void onColumnRemoved(TableModel m, int index, String h, List c) {} + @Override public void onCellChanged(TableModel m, int row, int col, String o, String n) {} + }); + + model.removeRow(0); + assertEquals(0, removedIndex[0]); + } +}