From e38205b2b242e013ddb0725bbdc03ece6c61d026 Mon Sep 17 00:00:00 2001 From: Eric Bottard Date: Mon, 28 Aug 2017 19:00:36 +0200 Subject: [PATCH] Add ASCII Table support Fixes #136 --- pom.xml | 6 + spring-shell-core/pom.xml | 18 +- .../shell/result/ResultHandlerConfig.java | 8 + .../TerminalSizeAwareResultHandler.java | 35 ++ .../shell/samples/standard/TableCommands.java | 60 +++ spring-shell-starter/pom.xml | 4 + spring-shell-table/pom.xml | 40 ++ .../shell/TerminalSizeAware.java | 30 ++ .../table/AbsoluteWidthSizeConstraints.java | 37 ++ .../springframework/shell/table/Aligner.java | 41 ++ .../shell/table/ArrayTableModel.java | 49 +++ .../shell/table/AutoSizeConstraints.java | 40 ++ .../shell/table/BeanListTableModel.java | 99 +++++ .../shell/table/BorderSpecification.java | 123 ++++++ .../shell/table/BorderStyle.java | 242 ++++++++++++ .../shell/table/CellMatcher.java | 35 ++ .../shell/table/CellMatchers.java | 93 +++++ .../shell/table/DebugAligner.java | 47 +++ .../shell/table/DebugTextWrapper.java | 42 ++ .../shell/table/DefaultFormatter.java | 30 ++ .../shell/table/DelimiterTextWrapper.java | 55 +++ .../shell/table/Formatter.java | 31 ++ .../table/KeyValueHorizontalAligner.java | 72 ++++ .../shell/table/KeyValueSizeConstraints.java | 92 +++++ .../shell/table/KeyValueTextWrapper.java | 69 ++++ .../shell/table/MapFormatter.java | 44 +++ .../shell/table/NoWrapSizeConstraints.java | 34 ++ .../shell/table/SimpleHorizontalAligner.java | 74 ++++ .../shell/table/SimpleVerticalAligner.java | 98 +++++ .../shell/table/SizeConstraints.java | 54 +++ .../springframework/shell/table/Table.java | 361 ++++++++++++++++++ .../shell/table/TableBuilder.java | 257 +++++++++++++ .../shell/table/TableModel.java | 66 ++++ .../shell/table/TableModelBuilder.java | 83 ++++ .../springframework/shell/table/Tables.java | 42 ++ .../shell/table/TextWrapper.java | 35 ++ 36 files changed, 2541 insertions(+), 5 deletions(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/result/TerminalSizeAwareResultHandler.java create mode 100644 spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/TableCommands.java create mode 100644 spring-shell-table/pom.xml create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/TerminalSizeAware.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/AbsoluteWidthSizeConstraints.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/Aligner.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/ArrayTableModel.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/AutoSizeConstraints.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/BeanListTableModel.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/BorderSpecification.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/BorderStyle.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/CellMatcher.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/CellMatchers.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/DebugAligner.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/DebugTextWrapper.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/DefaultFormatter.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/DelimiterTextWrapper.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/Formatter.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueHorizontalAligner.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueSizeConstraints.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueTextWrapper.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/MapFormatter.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/NoWrapSizeConstraints.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/SimpleHorizontalAligner.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/SimpleVerticalAligner.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/SizeConstraints.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/Table.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/TableBuilder.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/TableModel.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/TableModelBuilder.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/Tables.java create mode 100644 spring-shell-table/src/main/java/org/springframework/shell/table/TextWrapper.java diff --git a/pom.xml b/pom.xml index e3cf88ae..53514f54 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ spring-shell-shell1-adapter spring-shell-starter spring-shell-docs + spring-shell-table @@ -69,6 +70,11 @@ spring-shell-starter ${project.version} + + org.springframework.shell + spring-shell-table + ${project.version} + org.jline diff --git a/spring-shell-core/pom.xml b/spring-shell-core/pom.xml index 15b7a225..10ee7954 100644 --- a/spring-shell-core/pom.xml +++ b/spring-shell-core/pom.xml @@ -23,11 +23,6 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-test - test - org.jline jline @@ -36,6 +31,19 @@ org.jline jline-terminal-jna + + + org.springframework.shell + spring-shell-table + true + + + + + org.springframework.boot + spring-boot-starter-test + test + org.assertj assertj-core diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java index d1341a1d..86b6b8eb 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java @@ -19,9 +19,11 @@ package org.springframework.shell.result; import javax.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Bean; import org.springframework.shell.ResultHandler; +import org.springframework.shell.TerminalSizeAware; /** * Used for explicit configuration of {@link org.springframework.shell.ResultHandler}s. @@ -47,4 +49,10 @@ public class ResultHandlerConfig { iterableResultHandler().setDelegate(mainResultHandler()); } + @Bean + @ConditionalOnClass(TerminalSizeAware.class) + public TerminalSizeAwareResultHandler terminalSizeAwareResultHandler() { + return new TerminalSizeAwareResultHandler(); + } + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/TerminalSizeAwareResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/TerminalSizeAwareResultHandler.java new file mode 100644 index 00000000..8fa9679a --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/TerminalSizeAwareResultHandler.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.result; + +import org.springframework.shell.ResultHandler; +import org.springframework.shell.TerminalSizeAware; + +/** + * A ResultHandler that prints {@link TerminalSizeAware} according to the {@link org.jline.terminal.Terminal} size. + * + * @author Eric Bottard + */ +public class TerminalSizeAwareResultHandler extends TerminalAwareResultHandler implements ResultHandler { + + + @Override + public void handleResult(TerminalSizeAware result) { + CharSequence toPrint = result.render(terminal.getWidth()); + terminal.writer().println(toPrint); + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/TableCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/TableCommands.java new file mode 100644 index 00000000..f0bdf227 --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/TableCommands.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.samples.standard; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.table.*; + +import java.util.Random; + +@ShellComponent +public class TableCommands { + + private static final String TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " + + "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco " + + "laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in " + + "voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat " + + "non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; + + @ShellMethod("Showcase Table rendering") + public Table table() { + String[][] data = new String[3][3]; + TableModel model = new ArrayTableModel(data); + TableBuilder tableBuilder = new TableBuilder(model); + + Random r = new Random(); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + data[i][j] = TEXT.substring(0, TEXT.length() / 2 + r.nextInt(TEXT.length() / 2)); + tableBuilder.on(at(i, j)).addAligner(SimpleHorizontalAligner.values()[j]); + tableBuilder.on(at(i, j)).addAligner(SimpleVerticalAligner.values()[i]); + } + } + + return tableBuilder.addFullBorder(BorderStyle.fancy_light).build(); + } + + public static CellMatcher at(final int theRow, final int col) { + return new CellMatcher() { + @Override + public boolean matches(int row, int column, TableModel model) { + return row == theRow && column == col; + } + }; + } +} diff --git a/spring-shell-starter/pom.xml b/spring-shell-starter/pom.xml index 9494ff65..32e41a3a 100644 --- a/spring-shell-starter/pom.xml +++ b/spring-shell-starter/pom.xml @@ -36,6 +36,10 @@ org.springframework.shell spring-shell-jcommander-adapter + + org.springframework.shell + spring-shell-table + diff --git a/spring-shell-table/pom.xml b/spring-shell-table/pom.xml new file mode 100644 index 00000000..eea45b44 --- /dev/null +++ b/spring-shell-table/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + spring-shell-table + Spring Shell Table + jar + + + org.springframework.shell + spring-shell-parent + 2.0.0.BUILD-SNAPSHOT + + + Library to Display Fancy ASCII art Tables + + + + org.springframework + spring-beans + + + + diff --git a/spring-shell-table/src/main/java/org/springframework/shell/TerminalSizeAware.java b/spring-shell-table/src/main/java/org/springframework/shell/TerminalSizeAware.java new file mode 100644 index 00000000..00bf20db --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/TerminalSizeAware.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell; + +/** + * To be implemented by command result objects that can adapt to the terminal size when they are being rendered. + * + *

An object which does not implement this interface will simply be rendered by invoking its {@link Object#toString()} + * method.

+ * + * @author Eric Bottard + */ +public interface TerminalSizeAware { + + CharSequence render(int terminalWidth); +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/AbsoluteWidthSizeConstraints.java b/spring-shell-table/src/main/java/org/springframework/shell/table/AbsoluteWidthSizeConstraints.java new file mode 100644 index 00000000..a32e7721 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/AbsoluteWidthSizeConstraints.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A cell sizing strategy that forces a fixed width, expressed in number of characters. + * + * @author Eric Bottard + */ +public class AbsoluteWidthSizeConstraints implements SizeConstraints { + + private final int width; + + public AbsoluteWidthSizeConstraints(int width) { + this.width = width; + } + + + @Override + public Extent width(String[] raw, int previous, int tableWidth) { + return new Extent(width, width); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/Aligner.java b/spring-shell-table/src/main/java/org/springframework/shell/table/Aligner.java new file mode 100644 index 00000000..e81bc547 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/Aligner.java @@ -0,0 +1,41 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A strategy interface for performing text alignment. + * + * @author Eric Bottard + */ +public interface Aligner { + + /** + * Perform text alignment, returning a String array that MUST contain {@code cellHeight} + * lines, each of which MUST be {@code cellWidth} chars in length. + * + *

+ * Input array is guaranteed to contain lines that have length equal to {@code cellWidth}. + * There is no guarantee on the input number of lines though. + *

+ * + * @param text the text to align + * @param cellWidth the width of of the table cell + * @param cellHeight the height of the table cell + * @return the aligned text, in a {@code cellHeight} element array + */ + String[] align(String[] text, int cellWidth, int cellHeight); +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/ArrayTableModel.java b/spring-shell-table/src/main/java/org/springframework/shell/table/ArrayTableModel.java new file mode 100644 index 00000000..1b7baf81 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/ArrayTableModel.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import org.springframework.util.Assert; + +/** + * A TableModel backed by a row-first array. + * + * @author Eric Bottard + */ +public class ArrayTableModel extends TableModel { + + private Object[][] data; + + public ArrayTableModel(Object[][] data) { + this.data = data; + int width = data.length > 0 ? data[0].length : 0; + for (int row = 0; row < data.length; row++) { + Assert.isTrue(width == data[row].length, "All rows of array data must be of same length"); + } + } + + public int getRowCount() { + return data.length; + } + + public int getColumnCount() { + return data.length > 0 ? data[0].length : 0; + } + + public Object getValue(int row, int column) { + return data[row][column]; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/AutoSizeConstraints.java b/spring-shell-table/src/main/java/org/springframework/shell/table/AutoSizeConstraints.java new file mode 100644 index 00000000..7789427f --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/AutoSizeConstraints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A SizeConstraints implementation that splits lines at space boundaries + * and returns an extent with minimal and maximal width requirements. + * + * @author Eric Bottard + */ +public class AutoSizeConstraints implements SizeConstraints { + + @Override + public Extent width(String[] raw, int tableWidth, int nbColumns) { + int max = 0; + int min = 0; + for (String line : raw) { + String[] words = line.split(" "); + for (String word : words) { + min = Math.max(min, word.length()); + } + max = Math.max(max, line.length()); + } + return new Extent(min, max); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/BeanListTableModel.java b/spring-shell-table/src/main/java/org/springframework/shell/table/BeanListTableModel.java new file mode 100644 index 00000000..958a831e --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/BeanListTableModel.java @@ -0,0 +1,99 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.beans.PropertyDescriptor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; + +/** + * A table model that is backed by a list of beans. + * + *

One can control which properties are exposed (and their order). There is also + * a convenience constructor for adding a special header row.

+ * + * @author Eric Bottard + */ +public class BeanListTableModel extends TableModel { + + private final List data; + + private final List propertyNames; + + private final List headerRow; + + public BeanListTableModel(Class clazz, Iterable list) { + this.data = new ArrayList(); + for (T bean : list) { + this.data.add(new BeanWrapperImpl(bean)); + } + this.headerRow = null; + propertyNames = new ArrayList(); + for (PropertyDescriptor propertyName : BeanUtils.getPropertyDescriptors(clazz)) { + if ("class".equals(propertyName.getName())) { + continue; + } + propertyNames.add(propertyName.getName()); + } + } + + public BeanListTableModel(Iterable list, String... propertyNames) { + this.data = new ArrayList(); + for (T bean : list) { + this.data.add(new BeanWrapperImpl(bean)); + } + this.headerRow = null; + this.propertyNames = Arrays.asList(propertyNames); + } + + public BeanListTableModel(Iterable list, LinkedHashMap header) { + this.data = new ArrayList(); + for (T bean : list) { + this.data.add(new BeanWrapperImpl(bean)); + } + this.headerRow = new ArrayList(header.values()); + propertyNames = new ArrayList(header.keySet()); + } + + @Override + public int getRowCount() { + return headerRow == null ? data.size() : 1 + data.size(); + } + + @Override + public int getColumnCount() { + return propertyNames.size(); + } + + @Override + public Object getValue(int row, int column) { + if (headerRow != null && row == 0) { + return headerRow.get(column); + } + else { + int rowToUse = headerRow == null ? row : row - 1; + String propertyName = propertyNames.get(column); + return data.get(rowToUse).getPropertyValue(propertyName); + } + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/BorderSpecification.java b/spring-shell-table/src/main/java/org/springframework/shell/table/BorderSpecification.java new file mode 100644 index 00000000..f7227efc --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/BorderSpecification.java @@ -0,0 +1,123 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * This represents a directive to set some borders on cells of a table. + * Multiple specifications can be combined on a single table. + * + * @author Eric Bottard + */ +public class BorderSpecification { + + public static final int NONE = 0; + + public static final int TOP = 1; + + public static final int BOTTOM = 2; + + public static final int LEFT = 4; + + public static final int RIGHT = 8; + + public static final int INNER_VERTICAL = 16; + + public static final int INNER_HORIZONTAL = 32; + + public static final int OUTLINE = TOP | BOTTOM | LEFT | RIGHT; + + public static final int FULL = OUTLINE | INNER_HORIZONTAL | INNER_VERTICAL; + + public static final int INNER = INNER_HORIZONTAL | INNER_VERTICAL; + + private final int row1, row2, column1, column2; + + private final int match; + + private final BorderStyle style; + + /** + * Specifications are created by {@link Table#addBorder(int, int, int, int, int, BorderStyle)}. + */ + /*default*/ BorderSpecification(int row1, int column1, int row2, int column2, int match, BorderStyle style) { + this.row1 = row1; + this.row2 = row2; + this.column1 = column1; + this.column2 = column2; + this.match = match; + this.style = style; + } + + /** + * Does this specification result in the need to paint a vertical bar at row,column? + */ + /*default*/ char verticals(int row, int column) { + boolean result = (match & LEFT) == LEFT && column == column1; + result |= (match & INNER_VERTICAL) == INNER_VERTICAL && column > column1 && column < column2; + result |= (match & RIGHT) == RIGHT && column == column2; + + result &= row >= row1; + result &= row < row2; + return result ? style.verticalGlyph() : BorderStyle.NONE; + } + + /** + * Does this specification result in the need to paint an horizontal bar at row,column? + */ + /*default*/ char horizontals(int row, int column) { + boolean result = (match & TOP) == TOP && row == row1; + result |= (match & INNER_HORIZONTAL) == INNER_HORIZONTAL && row > row1 && row < row2; + result |= (match & BOTTOM) == BOTTOM && row == row2; + + result &= column >= column1; + result &= column < column2; + return result ? style.horizontalGlyph() : BorderStyle.NONE; + } + + @Override + public String toString() { + return String.format("%s[(%d, %d)->(%d, %d), %s, %s]", getClass().getSimpleName(), row1, column1, row2, column2, style, matchConstants()); + } + + private String matchConstants() { + try { + for (String field : new String[] {"NONE", "INNER", "FULL", "OUTLINE"}) { + int value = ReflectionUtils.findField(getClass(), field).getInt(null); + if (match == value) { + return field; + } + } + List constants = new ArrayList(); + for (String field : new String[] {"TOP", "BOTTOM", "LEFT", "RIGHT", "INNER_HORIZONTAL", "INNER_VERTICAL"}) { + int value = ReflectionUtils.findField(getClass(), field).getInt(null); + if ((match & value) == value) { + constants.add(field); + } + } + return StringUtils.collectionToDelimitedString(constants, "|"); + } + catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/BorderStyle.java b/spring-shell-table/src/main/java/org/springframework/shell/table/BorderStyle.java new file mode 100644 index 00000000..b6b69ea7 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/BorderStyle.java @@ -0,0 +1,242 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.table; + +import java.util.HashMap; +import java.util.Map; + +/** + * Provides support for different styles of borders, using simple or fancy ascii art. + * + * @see https://en.wikipedia.org/wiki/Box-drawing_character + * + * @author Eric Bottard + */ +public enum BorderStyle { + + /** + * A simplistic style, using characters that ought to always be available in all systems (pipe and minus). + */ + oldschool('|', '-'), + + /** + * A border style that uses dedicated light box drawing characters from the unicode set. + */ + fancy_light('│', '─'), + + /** + * A border style that uses dedicated fat box drawing characters from the unicode set. + */ + fancy_heavy('┃', '━'), + + /** + * A border style that uses dedicated double-light box drawing characters from the unicode set. + */ + fancy_double('║', '═'), + + /** + * A border style that uses space characters, giving some space between columns. + */ + air(' ', ' '), + + /** + * A border style that uses dedicated double dash light box drawing characters from the unicode set. + */ + fancy_light_double_dash('╎', '╌'), + + /** + * A border style that uses dedicated double dash light box drawing characters from the unicode set. + */ + fancy_light_triple_dash('┆', '┄'), + + /** + * A border style that uses dedicated double dash light box drawing characters from the unicode set. + */ + fancy_light_quadruple_dash('┊', '┈'), + + /** + * A border style that uses dedicated double dash heavy box drawing characters from the unicode set. + */ + fancy_heavy_double_dash('╏', '╍'), + + /** + * A border style that uses dedicated double dash heavy box drawing characters from the unicode set. + */ + fancy_heavy_triple_dash('┇', '┅'), + + /** + * A border style that uses dedicated double dash heavy box drawing characters from the unicode set. + */ + fancy_heavy_quadruple_dash('┋', '┉'), + + ; + + private char vertical; + + private char horizontal; + + public static final char NONE = '\u0000'; + + private static Map CORNERS = new HashMap(); + + private static Map EQUIVALENTS = new HashMap(); + + public char verticalGlyph() { + return vertical; + } + + public char horizontalGlyph() { + return horizontal; + } + + static { + registerCorners("─│┌┐└┘├┤┬┴┼"); + registerCorners("━┃┏┓┗┛┣┫┳┻╋"); + + // double dashes + registerCorners("╌╎┌┐└┘├┤┬┴┼"); + registerCorners("╍╏┏┓┗┛┣┫┳┻╋"); + + // triple dashes + registerCorners("┈┆┌┐└┘├┤┬┴┼"); + registerCorners("┅┇┏┓┗┛┣┫┳┻╋"); + + // quad dashes + registerCorners("┈┊┌┐└┘├┤┬┴┼"); + registerCorners("┉┋┏┓┗┛┣┫┳┻╋"); + + // double lines + registerCorners("═║╔╗╚╝╠╣╦╩╬"); + // oldschool + registerCorners("-|+++++++++"); + // air style + registerCorners(" "); + + // Register some mixed-style combinations + // light + heavy + registerCorner('│', '│', '━', NONE, '┥'); + registerCorner('│', '│', NONE, '━', '┝'); + registerCorner('┃', NONE, '─', '─', '┸'); + registerCorner(NONE, '┃', '─', '─', '┰'); + // heavy + light + registerCorner('┃', '┃', '─', NONE, '┨'); + registerCorner('┃', '┃', NONE, '─', '┠'); + registerCorner('│', NONE, '━', '━', '┷'); + registerCorner(NONE, '│', '━', '━', '┯'); + // double + single + registerCorner('║', '║', '─', NONE, '╢'); + registerCorner('║', '║', NONE, '─', '╟'); + registerCorner('│', NONE, '═', '═', '╧'); + registerCorner(NONE, '│', '═', '═', '╤'); + // single + double + registerCorner('│', '│', '═', NONE, '╡'); + registerCorner('│', '│', NONE, '═', '╞'); + registerCorner('║', NONE, '─', '─', '╨'); + registerCorner(NONE, '║', '─', '─', '╥'); + // heavy + light, 90° + registerCorner('┃', '│', '━', '─', '╃'); + registerCorner('│', '┃', '─', '━', '╆'); + registerCorner('┃', '│', '─', '━', '╄'); + registerCorner('│', '┃', '━', '─', '╅'); + // light crossing (heavy or double) + registerCorner('│', '│', '━', '━', '┿'); + registerCorner('│', '│', '═', '═', '╪'); + registerCorner('┃', '┃', '─', '─', '╂'); + registerCorner('║', '║', '─', '─', '╫'); + + // Dashed variants crossing others behave like regular corners + registerSameCorners(fancy_light_double_dash, fancy_light); + registerSameCorners(fancy_light_triple_dash, fancy_light); + registerSameCorners(fancy_light_quadruple_dash, fancy_light); + registerSameCorners(fancy_heavy_double_dash, fancy_heavy); + registerSameCorners(fancy_heavy_triple_dash, fancy_heavy); + registerSameCorners(fancy_heavy_quadruple_dash, fancy_heavy); + + + // Air-style glyphs are easy to combine with others. Register some combinations + registerMixedWithAirCombinations(oldschool.vertical, oldschool.horizontal); + registerMixedWithAirCombinations(fancy_light.vertical, fancy_light.horizontal); + registerMixedWithAirCombinations(fancy_double.vertical, fancy_double.horizontal); + registerMixedWithAirCombinations(fancy_heavy.vertical, fancy_heavy.horizontal); + + registerMixedWithAirCombinations(fancy_light_double_dash.vertical, fancy_light_double_dash.horizontal); + registerMixedWithAirCombinations(fancy_light_triple_dash.vertical, fancy_light_triple_dash.horizontal); + registerMixedWithAirCombinations(fancy_light_quadruple_dash.vertical, fancy_light_quadruple_dash.horizontal); + registerMixedWithAirCombinations(fancy_heavy_double_dash.vertical, fancy_heavy_double_dash.horizontal); + registerMixedWithAirCombinations(fancy_heavy_triple_dash.vertical, fancy_heavy_triple_dash.horizontal); + registerMixedWithAirCombinations(fancy_heavy_quadruple_dash.vertical, fancy_heavy_quadruple_dash.horizontal); + } + + /** + * Register the fact that for corner purposes, style1 behaves like style2. + */ + private static void registerSameCorners(BorderStyle style1, BorderStyle style2) { + EQUIVALENTS.put(style1.horizontal, style2.horizontal); + EQUIVALENTS.put(style1.vertical, style2.vertical); + } + + private static void registerMixedWithAirCombinations(char vertical, char horizontal) { + registerCorner(vertical, vertical, ' ', NONE, vertical); + registerCorner(vertical, vertical, NONE, ' ', vertical); + registerCorner(vertical, vertical, ' ', ' ', vertical); + registerCorner(' ', NONE, horizontal, horizontal, horizontal); + registerCorner(NONE, ' ', horizontal, horizontal, horizontal); + registerCorner(' ', ' ', horizontal, horizontal, horizontal); + + } + + /** + * Register corner glyphs for a given set, not taking care of mixed style intersections. + */ + private static void registerCorners(String list) { + char horizontal = list.charAt(0); + char vertical = list.charAt(1); + registerCorner(NONE, vertical, NONE, horizontal, list.charAt(2)); + registerCorner(NONE, vertical, horizontal, NONE, list.charAt(3)); + registerCorner(vertical, NONE, NONE, horizontal, list.charAt(4)); + registerCorner(vertical, NONE, horizontal, NONE, list.charAt(5)); + registerCorner(vertical, vertical, NONE, horizontal, list.charAt(6)); + registerCorner(vertical, vertical, horizontal, NONE, list.charAt(7)); + registerCorner(NONE, vertical, horizontal, horizontal, list.charAt(8)); + registerCorner(vertical, NONE, horizontal, horizontal, list.charAt(9)); + registerCorner(vertical, vertical, horizontal, horizontal, list.charAt(10)); + + } + + private static void registerCorner(char above, char below, char left, char right, char corner) { + long key = key(above, below, left, right); + CORNERS.put(key, corner); + } + + public static char intersection(char above, char below, char left, char right) { + above = EQUIVALENTS.get(above) != null ? EQUIVALENTS.get(above) : above; + below = EQUIVALENTS.get(below) != null ? EQUIVALENTS.get(below) : below; + left = EQUIVALENTS.get(left) != null ? EQUIVALENTS.get(left) : left; + right = EQUIVALENTS.get(right) != null ? EQUIVALENTS.get(right) : right; + Character character = CORNERS.get(key(above, below, left, right)); + return character != null ? character : NONE; + } + + private static long key(char above, char below, char left, char right) { + return (long) above << 48 | (long) below << 32 | (long) left << 16 | (long) right; + } + + BorderStyle(char vertical, char horizontal) { + this.vertical = vertical; + this.horizontal = horizontal; + } + +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatcher.java b/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatcher.java new file mode 100644 index 00000000..aee78ec3 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatcher.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * This is used to specify where some components of a Table may be applied. + * + *

Some commonly used matchers can be created via {@link CellMatchers}.

+ * + * @author Eric Bottard + */ +public interface CellMatcher { + + /** + * @return whether a given cell of the table should match. + * @param row the row being tested. + * @param column the column being tested + * @param model the data model of the table + */ + public boolean matches(int row, int column, TableModel model); +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatchers.java b/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatchers.java new file mode 100644 index 00000000..d0228314 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/CellMatchers.java @@ -0,0 +1,93 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * Contains factory methods for commonly used {@link CellMatcher}s. + * + * @author Eric Bottard + */ +public class CellMatchers { + + /** + * @return a matcher that applies to every cell of the table. + */ + public static CellMatcher table() { + return new CellMatcher() { + public boolean matches(int row, int column, TableModel model) { + return true; + } + }; + } + + /** + * @return a matcher that applies to every cell of some column of the table. + * @param col the column to select + */ + public static CellMatcher column(final int col) { + return new CellMatcher() { + public boolean matches(int row, int column, TableModel model) { + return col == column; + } + }; + } + + /** + * @return a matcher that applies to every cell of some row of the table. + * @param theRow the row to select + */ + public static CellMatcher row(final int theRow) { + return new CellMatcher() { + public boolean matches(int row, int column, TableModel model) { + return theRow == row; + } + }; + } + + /** + * @param theRow the row to select + * @param col the column to select + * @return a matcher that applies to exactly one cell of the table, identified by its row and colum. + */ + public static CellMatcher at(final int theRow, final int col) { + return new CellMatcher() { + @Override + public boolean matches(int row, int column, TableModel model) { + return row == theRow && column == col; + } + }; + } + + /** + * @param clazz the type that cells should contain + * @return a matcher that matches cells whose content is of a certain type + */ + public static CellMatcher ofType(final Class clazz) { + return new CellMatcher() { + @Override + public boolean matches(int row, int column, TableModel model) { + Object value = model.getValue(row, column); + if (value == null) { + return false; + } + else { + return clazz.isAssignableFrom(value.getClass()); + } + } + }; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/DebugAligner.java b/spring-shell-table/src/main/java/org/springframework/shell/table/DebugAligner.java new file mode 100644 index 00000000..b1a9bd74 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/DebugAligner.java @@ -0,0 +1,47 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.Arrays; + +import org.springframework.util.Assert; + +/** + * A decorator Aligner that checks the Aligner invariants contract, useful for debugging. + * + * @author Eric Bottard + */ +public class DebugAligner implements Aligner { + + private final Aligner delegate; + + public DebugAligner(Aligner delegate) { + this.delegate = delegate; + } + + @Override + public String[] align(String[] text, int cellWidth, int cellHeight) { + String[] result = delegate.align(text, cellWidth, cellHeight); + Assert.isTrue(result.length == cellHeight, String.format("%s had the wrong number of lines (%d), expected %d", + Arrays.asList(result), result.length, cellHeight)); + for (String s : result) { + Assert.isTrue(s.length() == cellWidth, String.format("'%s' had wrong length (%d), expected %d", s, s.length(), + cellWidth)); + } + return result; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/DebugTextWrapper.java b/spring-shell-table/src/main/java/org/springframework/shell/table/DebugTextWrapper.java new file mode 100644 index 00000000..1e105172 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/DebugTextWrapper.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import org.springframework.util.Assert; + +/** + * A TextWrapper that delegates to another but makes sure that the contract is not violated. + * + * @author Eric Bottard + */ +public class DebugTextWrapper implements TextWrapper { + + private final TextWrapper delegate; + + public DebugTextWrapper(TextWrapper delegate) { + this.delegate = delegate; + } + + @Override + public String[] wrap(String[] original, int columnWidth) { + String[] result = delegate.wrap(original, columnWidth); + for (String s : result) { + Assert.isTrue(s.length() == columnWidth, String.format("'%s' has the wrong length (%d), expected %d", s, s.length(), columnWidth)); + } + return result; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/DefaultFormatter.java b/spring-shell-table/src/main/java/org/springframework/shell/table/DefaultFormatter.java new file mode 100644 index 00000000..c11c711b --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/DefaultFormatter.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A very simple formatter that uses {@link Object#toString()} and splits on newlines. + * + * @author Eric Bottard + */ +public class DefaultFormatter implements Formatter { + + public String[] format(Object value) { + return value == null ? new String[] {""} : value.toString().split("\n"); + } + +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/DelimiterTextWrapper.java b/spring-shell-table/src/main/java/org/springframework/shell/table/DelimiterTextWrapper.java new file mode 100644 index 00000000..b4a3422b --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/DelimiterTextWrapper.java @@ -0,0 +1,55 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.ArrayList; +import java.util.List; + +/** + * A Text wrapper that wraps at "word" boundaries. The default delimiter is the space character. + * + * @author Eric Bottard + */ +public class DelimiterTextWrapper implements TextWrapper { + + private final char delimiter; + + public DelimiterTextWrapper() { + this(' '); + } + + public DelimiterTextWrapper(char delimiter) { + this.delimiter = delimiter; + } + + @Override + public String[] wrap(String[] original, int columnWidth) { + List result = new ArrayList(original.length); + for (String line : original) { + while (line.length() > columnWidth) { + int split = line.lastIndexOf(delimiter, columnWidth); + String toAdd = split == -1 ? line.substring(0, columnWidth) : line.substring(0, split); + result.add(String.format("%-" + columnWidth + "s", toAdd)); + line = line.substring(split == -1 ? columnWidth : split + 1); + } + if (columnWidth > 0) { + result.add(String.format("%-" + columnWidth + "s", line)); // right pad if necessary + } + } + return result.toArray(new String[result.size()]); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/Formatter.java b/spring-shell-table/src/main/java/org/springframework/shell/table/Formatter.java new file mode 100644 index 00000000..36f1573a --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/Formatter.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A Formatter is responsible for the initial rendering of a value to lines of text. + * + *

Note that this representation is likely to be altered later in the pipeline, for the + * purpose of text wrapping and aligning. The role of a formatter is merely to give the + * raw text representation (e.g. format numbers).

+ * + * @author Eric Bottard + */ +public interface Formatter { + + public String[] format(Object value); +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueHorizontalAligner.java b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueHorizontalAligner.java new file mode 100644 index 00000000..30fde797 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueHorizontalAligner.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A text alignment strategy that aligns text horizontally so that all instances of some special character(s) + * line up perfectly in a column. + * + *

Typically used to render numbers which may or may not have a decimal point, or series of key-value pairs

+ * + * @author Eric Bottard + */ +public class KeyValueHorizontalAligner implements Aligner { + + private final String delimiter; + + public KeyValueHorizontalAligner(String delimiter) { + this.delimiter = delimiter; + } + + @Override + public String[] align(String[] text, int cellWidth, int cellHeight) { + + String[] result = new String[cellHeight]; + int alignOffset = 0; + for (String line : text) { + alignOffset = Math.max(alignOffset, line.trim().indexOf(delimiter)); + } + int i = 0; + for (String line : text) { + String trimmed = line.trim(); + int offset = trimmed.indexOf(delimiter); + if (offset >= 0) { + // It is possible that aligning would trigger overflow + // Make sure not to + int offsetToUse = Math.min(alignOffset - offset, cellWidth - trimmed.length()); + result[i++] = pad(offsetToUse, cellWidth - trimmed.length() - offsetToUse, trimmed); + } + else { + result[i++] = pad(0, cellWidth - line.length(), line); + } + + } + return result; + } + + private String pad(int left, int right, String original) { + StringBuilder sb = new StringBuilder(left + original.length() + right); + for (int i = 0; i < left; i++) { + sb.append(' '); + } + sb.append(original); + for (int i = 0; i < right; i++) { + sb.append(' '); + } + return sb.toString(); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueSizeConstraints.java b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueSizeConstraints.java new file mode 100644 index 00000000..84550f85 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueSizeConstraints.java @@ -0,0 +1,92 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A SizeConstraints implementation that is tailored to rendering a series + * of {@literal key = value} pairs. Computes extents so that equal signs (or any other + * configurable delimiter) line up vertically. + * + * @author Eric Bottard + */ +public class KeyValueSizeConstraints implements SizeConstraints { + + private final String delimiter; + + public KeyValueSizeConstraints(String delimiter) { + this.delimiter = delimiter; + } + + private static String leftTrim(String raw) { + int start = 0; + int length = raw.length(); + while (start < length && raw.charAt(start) == ' ') { + start++; + } + return raw.substring(start); + } + + private static String rightTrim(String raw) { + int end = raw.length(); + while (end > 0 && raw.charAt(end - 1) == ' ') { + end--; + } + return raw.substring(0, end); + } + + @Override + public Extent width(String[] raw, int tableWidth, int nbColumns) { + + // We need to make sure we take care of the case where we have + // k = long-value + // long-key = v + // as the real maximal extent is size(long-key) + size( = ) + size(long-value) + + // The minimal extent in the example above is size(long-value) + + int maxLeft = 0; + int maxRight = 0; + int min = 0; + for (String line : raw) { + String lineToConsider = line.trim(); + int offset = lineToConsider.indexOf(delimiter); + + if (offset != -1) { + // Compute minimal case (line can be split, decide where to put the delimiter) + String minimalLeftPart = lineToConsider.substring(0, offset).trim(); + String minimalRightPart = lineToConsider.substring(offset + delimiter.length()).trim(); + int left = minimalLeftPart.length(); + int right = minimalRightPart.length(); + int case1 = Math.max(left, right + leftTrim(delimiter).length()); + int case2 = Math.max(left + rightTrim(delimiter).length(), right); + int bestMin = Math.min(case1, case2); + min = Math.max(min, bestMin); + + // Compute maximal case (sum of worst case on left and right) + maxLeft = Math.max(maxLeft, offset); + int after = lineToConsider.length() - offset - delimiter.length(); + maxRight = Math.max(maxRight, after); + } + else { + min = Math.max(min, lineToConsider.length()); + } + + } + + return new Extent(min, maxLeft + delimiter.length() + maxRight); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueTextWrapper.java b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueTextWrapper.java new file mode 100644 index 00000000..18bcf65c --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/KeyValueTextWrapper.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.ArrayList; +import java.util.List; + +/** + * A TextWrapper implementation tailored for key-value rendering (working in concert + * with {@link KeyValueSizeConstraints}, {@link KeyValueHorizontalAligner}), that tries its + * best to vertically align some delimiter character (default '='). + */ +public class KeyValueTextWrapper implements TextWrapper { + + private final String delimiter; + + public KeyValueTextWrapper() { + this("="); + } + + public KeyValueTextWrapper(String delimiter) { + this.delimiter = delimiter; + } + + @Override + public String[] wrap(String[] original, int columnWidth) { + List result = new ArrayList(); + for (String line : original) { + line = line.trim(); + while (line.length() > columnWidth) { + int cut = line.lastIndexOf(delimiter, columnWidth); + if (cut == -1) { + cut = columnWidth; + } + else if (cut + delimiter.length() <= columnWidth) { + cut = cut + delimiter.length(); + } + result.add(rightPad(line.substring(0, cut), columnWidth)); + line = line.substring(cut).trim(); + } + if (line.length() > 0) { + result.add(rightPad(line, columnWidth)); + } + } + return result.toArray(new String[result.size()]); + } + + private String rightPad(String raw, int width) { + StringBuilder result = new StringBuilder(raw); + for (int i = raw.length(); i < width; i++) { + result.append(' '); + } + return result.toString(); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/MapFormatter.java b/spring-shell-table/src/main/java/org/springframework/shell/table/MapFormatter.java new file mode 100644 index 00000000..7ea3b3dc --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/MapFormatter.java @@ -0,0 +1,44 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.Map; + +/** + * A formatter suited for key-value pairs, that renders each mapping on a new line. + * + * @author Eric Bottard + */ +public class MapFormatter implements Formatter { + + private final String separator; + + public MapFormatter(String separator) { + this.separator = separator; + } + + @Override + public String[] format(Object value) { + Map map = (Map) value; + String[] result = new String[map.size()]; + int i = 0; + for (Map.Entry kv : map.entrySet()) { + result[i++] = kv.getKey() + separator + kv.getValue(); + } + return result; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/NoWrapSizeConstraints.java b/spring-shell-table/src/main/java/org/springframework/shell/table/NoWrapSizeConstraints.java new file mode 100644 index 00000000..8e7a0d0b --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/NoWrapSizeConstraints.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A sizing strategy that will impose the longest line width on cells. + * + * @author Eric Bottard + */ +public class NoWrapSizeConstraints implements SizeConstraints { + + @Override + public Extent width(String[] raw, int tableWidth, int nbColumns) { + int max = 0; + for (String line : raw) { + max = Math.max(max, line.length()); + } + return new Extent(max, max); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleHorizontalAligner.java b/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleHorizontalAligner.java new file mode 100644 index 00000000..bdd6749f --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleHorizontalAligner.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * An horizontal alignment strategy that allows alignment to the left, center or right. + * + * @author Eric Bottard + */ +public enum SimpleHorizontalAligner implements Aligner { + + left, center, right; + + @Override + public String[] align(String[] text, int cellWidth, int cellHeight) { + String[] result = new String[cellHeight]; + for (int i = 0; i < cellHeight; i++) { + String line = (i < text.length && text[i] != null) ? text[i].trim() : ""; + + int paddingToDistribute = cellWidth - line.length(); + + int padLeft; + int padRight; + + switch (this) { + case center: { + int carry = paddingToDistribute % 2; + paddingToDistribute = paddingToDistribute - carry; + padLeft = padRight = paddingToDistribute / 2; + padRight += carry; + break; + } + case right: { + padLeft = paddingToDistribute; + padRight = 0; + break; + } + case left: { + padLeft = 0; + padRight = paddingToDistribute; + break; + } + default: + throw new AssertionError(); + } + StringBuilder sb = new StringBuilder(cellWidth); + for (int j = 0; j < padLeft; j++) { + sb.append(' '); + } + sb.append(line); + for (int j = 0; j < padRight; j++) { + sb.append(' '); + } + + result[i] = sb.toString(); + } + return result; + } + +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleVerticalAligner.java b/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleVerticalAligner.java new file mode 100644 index 00000000..f3e230c5 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/SimpleVerticalAligner.java @@ -0,0 +1,98 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.Arrays; + +/** + * Alignment strategy that allows simple vertical alignment to top, middle or bottom. + * + * @author Eric Bottard + */ +public enum SimpleVerticalAligner implements Aligner { + + top, middle, bottom; + + @Override + public String[] align(String[] text, int cellWidth, int cellHeight) { + String[] result = new String[cellHeight]; + int blanksBefore = 0; + int blanksAfter = 0; + boolean atLeastOneNonEmptyRow = false; + for (int row = 0; row < text.length; row++) { + if (text[row] == null || text[row].trim().equals("")) { + blanksBefore++; + } + else { + atLeastOneNonEmptyRow = true; + break; + } + } + // In case of full blank, don't count blank rows twice + if (atLeastOneNonEmptyRow) { + for (int row = text.length - 1; row >= 0; row--) { + if (text[row] == null || text[row].trim().equals("")) { + blanksAfter++; + } + else { + break; + } + } + } + String filler = spaces(cellWidth); + + int padBefore; + int padAfter; + int nonBlankLines = text.length - blanksAfter - blanksBefore; + int paddingToDistribute = cellHeight - nonBlankLines; + + switch (this) { + case middle: { + int carry = paddingToDistribute % 2; + paddingToDistribute = paddingToDistribute - carry; + padBefore = padAfter = paddingToDistribute / 2; + padAfter += carry; + break; + } + case bottom: { + padBefore = paddingToDistribute; + padAfter = 0; + break; + } + case top: { + padBefore = 0; + padAfter = paddingToDistribute; + break; + } + default: + throw new AssertionError(); + } + + Arrays.fill(result, 0, padBefore, filler); + System.arraycopy(text, blanksBefore, result, padBefore, nonBlankLines); + Arrays.fill(result, result.length - padAfter, result.length, filler); + + return result; + } + + private String spaces(int width) { + char[] data = new char[width]; + Arrays.fill(data, ' '); + return new String(data); + } + +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/SizeConstraints.java b/spring-shell-table/src/main/java/org/springframework/shell/table/SizeConstraints.java new file mode 100644 index 00000000..cec862c6 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/SizeConstraints.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import org.springframework.util.Assert; + +/** + * Strategy for computing the dimensions of a table cell. + * + * @author Eric Bottard + */ +public interface SizeConstraints { + + /** + * @return the minimum and maximum width of the cell, given its raw content. + * @param raw the raw String representation of the cell contents (may be reformatted later, eg wrapped) + * @param tableWidth the whole available width for the table + * @param nbColumns the number of columns in the table + */ + Extent width(String[] raw, int tableWidth, int nbColumns); + + /** + * Holds both a minimum and maximum width. + * + * @author Eric Bottard + */ + class Extent { + + public final int min; + + public final int max; + + public Extent(int min, int max) { + Assert.isTrue(min <= max, "min must be less than max"); + Assert.isTrue(0 <= min, "min and max must be positive"); + this.min = min; + this.max = max; + } + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/Table.java b/spring-shell-table/src/main/java/org/springframework/shell/table/Table.java new file mode 100644 index 00000000..8b7b8402 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/Table.java @@ -0,0 +1,361 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import static org.springframework.shell.table.BorderSpecification.NONE; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.shell.TerminalSizeAware; + +/** + * This is the central API for table rendering. A Table object is constructed with a given + * TableModel, which holds raw table contents. Its rendering logic is then altered by applying + * various customizations, in a fashion very similar to what is used e.g. in a spreadsheet + * program:
    + *
  1. {@link #formatters formatters} know how to derive character data out of raw data. For + * example, numbers are + * formatted according to a Locale, or Maps are emitted as a series of {@literal key=value} lines
  2. + *
  3. {@link #sizeConstraints size constraints} are then applied, which decide how + * much column real estate to allocate to cells
  4. + *
  5. {@link #wrappers text wrapping policies} are applied once the column sizes + * are known
  6. + *
  7. finally, {@link #aligners alignment} strategies actually render + * text as a series of space-padded strings that draw nicely on screen.
  8. + *
+ * All those customizations are applied selectively on the Table cells thanks to a {@link CellMatcher}: One can + * decide to right pad column number 3, or to format in a certain way all instances of {@literal java.util.Map}. + * + *

Of course, all of those customizations often work hand in hand, and not all combinations make sense: + * one needs to anticipate the fact that text will be split using the ' ' (space) character to properly + * calculate column sizes.

+ * @author Eric Bottard + */ +public class Table implements TerminalSizeAware { + + private final int rows; + + private final int columns; + + private TableModel model; + + private Map formatters = new LinkedHashMap(); + + private Map sizeConstraints = new LinkedHashMap(); + + private Map wrappers = new LinkedHashMap(); + + private Map aligners = new LinkedHashMap(); + + private List borderSpecifications = new ArrayList(); + + /** + * Construct a new Table with the given model and customizers. + * The passed in LinkedHashMap should be in reverse-insertion order (i.e. the first CellMatcher + * found in iteration order will "win"). + * + * @see TableBuilder#build() + */ + /*package*/ Table(TableModel model, + LinkedHashMap formatters, + LinkedHashMap sizeConstraints, + LinkedHashMap wrappers, + LinkedHashMap aligners, + List borderSpecifications) { + this.model = model; + this.formatters = formatters; + this.sizeConstraints = sizeConstraints; + this.wrappers = wrappers; + this.aligners = aligners; + this.borderSpecifications = borderSpecifications; + rows = model.getRowCount(); + columns = model.getColumnCount(); + + } + + public TableModel getModel() { + return model; + } + + public String render(int totalAvailableWidth) { + StringBuilder result = new StringBuilder(); + + int[] cellHeights = new int[rows]; + int[] cellWidths; + int[] minCellWidths = new int[columns]; + int[] maxCellWidths = new int[columns]; + + String[][][] subLines = new String[rows][columns][]; + + Borders borders = new Borders(); + int widthAvailableForContents = totalAvailableWidth - borders.getNumberOfVerticalBorders(); + + // First, compute desired column widths + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + Object value = model.getValue(row, column); + String[] lines = getFormatter(row, column).format(value); + subLines[row][column] = lines; + + SizeConstraints.Extent extent = getSizeConstraints(row, column).width(lines, widthAvailableForContents, columns); + + minCellWidths[column] = Math.max(minCellWidths[column], extent.min); + maxCellWidths[column] = Math.max(maxCellWidths[column], extent.max); + + } + } + + + cellWidths = computeColumnWidths(widthAvailableForContents, minCellWidths, maxCellWidths); + // Now that widths are known, apply wrapping & render + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + subLines[row][column] = getWrapper(row, column).wrap(subLines[row][column], cellWidths[column]); + cellHeights[row] = Math.max(cellHeights[row], subLines[row][column].length); + } + for (int column = 0; column < columns; column++) { + for (Map.Entry kv : aligners.entrySet()) { + if (kv.getKey().matches(row, column, model)) { + subLines[row][column] = kv.getValue().align(subLines[row][column], cellWidths[column], cellHeights[row]); + } + } + } + } + + + for (int row = 0; row < rows; row++) { + + // TOP CELL BORDER + int before = result.length(); + for (int column = 0; column < columns; column++) { + borders.paintCorner(row, column, result); + borders.paintHorizontal(row, column, cellWidths[column], result); + } + borders.paintCorner(row, columns, result); + if (result.length() > before) { + result.append('\n'); + } + + for (int subRow = 0; subRow < cellHeights[row]; subRow++) { + for (int column = 0; column < columns; column++) { + // LEFT CELL BORDER + borders.paintVertical(row, column, result); + String[] lines = subLines[row][column]; + result.append(lines[subRow]); + } + // TABLE RIGHT BORDER + borders.paintVertical(row, columns, result); + result.append("\n"); + } + } + + // TABLE BOTTOM BORDER + int before = result.length(); + for (int column = 0; column < columns; column++) { + borders.paintCorner(rows, column, result); + borders.paintHorizontal(rows, column, cellWidths[column], result); + } + + // TABLE BOTTOM RIGHT CORNER + borders.paintCorner(rows, columns, result); + if (result.length() > before) { + result.append('\n'); + } + return result.toString(); + } + + private int[] computeColumnWidths(int availableWidth, int[] minCellWidths, int[] maxCellWidths) { + + int[] cellWidths; + int minTableWidth = 0, maxTableWidth = 0; + for (int column = 0; column < columns; column++) { + minTableWidth += minCellWidths[column]; + maxTableWidth += maxCellWidths[column]; + } + + // Can use max desired width + if (maxTableWidth <= availableWidth) { + cellWidths = maxCellWidths; + } // will overflow + else if (minTableWidth >= availableWidth) { + cellWidths = minCellWidths; + } // Redistribute nicely + else { + int W = availableWidth - minTableWidth; + int D = maxTableWidth - minTableWidth; + cellWidths = new int[columns]; + for (int column = 0; column < columns; column++) { + cellWidths[column] = minCellWidths[column] + W * (maxCellWidths[column] - minCellWidths[column]) / D; + } + } + return cellWidths; + } + + private TextWrapper getWrapper(int row, int column) { + for (Map.Entry kv : wrappers.entrySet()) { + if (kv.getKey().matches(row, column, model)) { + return kv.getValue(); + } + } + throw new AssertionError("Can't be reached thanks to the whole-table default"); + } + + private SizeConstraints getSizeConstraints(int row, int column) { + for (Map.Entry kv : sizeConstraints.entrySet()) { + if (kv.getKey().matches(row, column, model)) { + return kv.getValue(); + } + } + throw new AssertionError("Can't be reached thanks to the whole-table default"); + } + + private Formatter getFormatter(int row, int column) { + for (Map.Entry kv : formatters.entrySet()) { + if (kv.getKey().matches(row, column, model)) { + return kv.getValue(); + } + } + throw new AssertionError("Can't be reached thanks to the whole-table default"); + } + + /** + * An instance of this class knows where to paint border glyphs. + * + *

In all instance arrays, 'row' and 'column' are actually indices in-between + * table rows and columns. Hence, sizes are larger by one.

+ * @author Eric Bottard + */ + private class Borders { + + /** + * Glyph to paint a vertical line at row,col. + */ + private char[][] verticals; + + /** + * Glyph to paint a horizontal line at row,col. + */ + private char[][] horizontals; + + /** + * The type of corner, if any, to paint at row,col. + */ + private char[][] corners; + + /** + * True if at least one vertical bar exists in that col. + */ + private boolean[] vFillers; + + /** + * True if at least one horizontal bar exists in that row. + */ + private boolean[] hFillers; + + public Borders() { + verticals = new char[rows][columns + 1]; + horizontals = new char[rows + 1][columns]; + corners = new char[rows + 1][columns + 1]; + vFillers = new boolean[columns + 1]; + hFillers = new boolean[rows + 1]; + init(); + } + + private void init() { + + for (int row = 0; row <= rows; row++) { + for (int column = 0; column <= columns; column++) { + for (BorderSpecification bs : borderSpecifications) { + if (row < rows) { + char verticalThere = bs.verticals(row, column); + if (verticalThere != BorderStyle.NONE) { + this.verticals[row][column] = verticalThere; + vFillers[column] |= true; + } + } + if (column < columns) { + char horizontalThere = bs.horizontals(row, column); + if (horizontalThere != BorderStyle.NONE) { + this.horizontals[row][column] = horizontalThere; + hFillers[row] |= true; + } + } + } + } + } + + // Compute corners when horizontals & verticals intersect + for (int row = 0; row <= rows; row++) { + for (int column = 0; column <= columns; column++) { + char left = (column - 1 >= 0) ? horizontals[row][column - 1] : NONE; + char right = (column < columns) ? horizontals[row][column] : NONE; + char above = (row - 1 >= 0) ? verticals[row - 1][column] : NONE; + char below = (row < rows) ? verticals[row][column] : NONE; + corners[row][column] = BorderStyle.intersection(above, below, left, right); + } + } + } + + private void paintCorner(int row, int column, StringBuilder stringBuilder) { + if (corners[row][column] != NONE) { + stringBuilder.append(corners[row][column]); + } // If there is a border in same row|column, paint filler + else if (vFillers[column] && hFillers[row]) { + stringBuilder.append(' '); + } + } + + private void paintVertical(int row, int column, StringBuilder stringBuilder) { + if (verticals[row][column] != NONE) { + stringBuilder.append(verticals[row][column]); + } + else if (vFillers[column]) { + stringBuilder.append(' '); + } + } + + private void paintHorizontal(int row, int column, int width, StringBuilder stringBuilder) { + if (horizontals[row][column] != NONE) { + for (int i = 0; i < width; i++) { + stringBuilder.append(horizontals[row][column]); + } + } + else if (hFillers[row]) { + for (int i = 0; i < width; i++) { + stringBuilder.append(' '); + } + } + } + + /** + * Return the number of vertical borders, and hence the space consumed by those. + */ + public int getNumberOfVerticalBorders() { + int result = 0; + for (boolean b : vFillers) { + if (b) { + result++; + } + } + return result; + } + } + +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/TableBuilder.java b/spring-shell-table/src/main/java/org/springframework/shell/table/TableBuilder.java new file mode 100644 index 00000000..2a3bebda --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/TableBuilder.java @@ -0,0 +1,257 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import static org.springframework.shell.table.BorderSpecification.FULL; +import static org.springframework.shell.table.BorderSpecification.INNER; +import static org.springframework.shell.table.BorderSpecification.INNER_VERTICAL; +import static org.springframework.shell.table.BorderSpecification.OUTLINE; +import static org.springframework.shell.table.SimpleHorizontalAligner.left; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * A builder class to incrementally configure a Table. + * @author Eric Bottard + */ +public class TableBuilder { + + private final TableModel model; + + private final Map formatters = new LinkedHashMap(); + + private final Map sizeConstraints = new LinkedHashMap(); + + private final Map wrappers = new LinkedHashMap(); + + private final LinkedHashMap aligners = new LinkedHashMap(); + + private final List borderSpecifications = new ArrayList(); + + private final int rows; + + private final int columns; + + /** + * Construct a table with the given model. The table will use the following + * strategies for all cells, unless overridden:
    + *
  • {@link DefaultFormatter default formatting} using {@literal toString()}
  • + *
  • {@link AutoSizeConstraints sizing strategy} trying to use the maximum space, resorting to splitting lines on + * spaces
  • + *
  • {@link DelimiterTextWrapper wrapping text} on space characters
  • + *
  • {@link SimpleHorizontalAligner left aligning} text.
  • + *
+ * + * @param model the data model of the table to construct + */ + + public TableBuilder(TableModel model) { + this.model = model; + rows = model.getRowCount(); + columns = model.getColumnCount(); + + formatters.put(CellMatchers.table(), new DefaultFormatter()); + sizeConstraints.put(CellMatchers.table(), new AutoSizeConstraints()); + wrappers.put(CellMatchers.table(), new DelimiterTextWrapper()); + aligners.put(CellMatchers.table(), left); + + } + + private TableBuilder addBorder(int top, int left, int bottom, int right, int match, BorderStyle style) { + Assert.isTrue(top >= 0 && top < rows, "top row must be positive and less than total number of rows"); + Assert.isTrue(left >= 0 && left < columns, "left column must be positive and less than total number of columns"); + Assert.isTrue(bottom > top && bottom <= rows, "bottom row must be greater than top and less than total number of rows"); + Assert.isTrue(right >= left && right <= columns, "right column must be greater than left and less than total number of columns"); + Assert.notNull(style, "style cannot be null"); + borderSpecifications.add(new BorderSpecification(top, left, bottom, right, match, style)); + return this; + } + + public TableModel getModel() { + return model; + } + + public CellMatcherStub on(CellMatcher matcher) { + return new CellMatcherStub(matcher); + } + + public Table build() { + return new Table(model, + reverse(formatters), + reverse(sizeConstraints), + reverse(wrappers), + aligners, + borderSpecifications); + } + + public BorderStub paintBorder(BorderStyle style, int match) { + return new BorderStub(style, match); + } + + // Convenience methods for borders + + /** + * Set a border on the outline of the whole table. + * @param style the style to apply + * @return this, for method chaining + */ + public TableBuilder addOutlineBorder(BorderStyle style) { + this.addBorder(0, 0, model.getRowCount(), model.getColumnCount(), OUTLINE, style); + return this; + } + + /** + * Set a border on the outline of the whole table, as well as around the first row. + * @param style the style to apply + * @return this, for method chaining + */ + public TableBuilder addHeaderBorder(BorderStyle style) { + this.addBorder(0, 0, 1, model.getColumnCount(), OUTLINE, style); + return addOutlineBorder(style); + } + + /** + * Set a border around each and every cell of the table. + * + * @param style the style to apply + * @return this, for method chaining + */ + public TableBuilder addFullBorder(BorderStyle style) { + this.addBorder(0, 0, model.getRowCount(), model.getColumnCount(), FULL, style); + return this; + } + + /** + * Set a border on the outline of the whole table, around the first row and draw vertical lines + * around each column. + * + * @param style the style to apply + * @return this, for method chaining + */ + public TableBuilder addHeaderAndVerticalsBorders(BorderStyle style) { + this.addBorder(0, 0, 1, model.getColumnCount(), OUTLINE, style); + this.addBorder(0, 0, model.getRowCount(), model.getColumnCount(), OUTLINE | INNER_VERTICAL, style); + return this; + } + + /** + * Set a border on the inner verticals and horizontals of the table, but not on the outline. + * + * @param style the style to apply + * @return this, for method chaining + */ + public TableBuilder addInnerBorder(BorderStyle style) { + this.addBorder(0, 0, model.getRowCount(), model.getColumnCount(), INNER, style); + return this; + } + + private LinkedHashMap reverse(Map original) { + LinkedHashMap result = new LinkedHashMap(original.size()); + List> entries = new ArrayList>(original.entrySet()); + for (int i = entries.size() - 1; i >= 0; i--) { + Map.Entry entry = entries.get(i); + result.put(entry.getKey(), entry.getValue()); + } + return result; + } + + public class CellMatcherStub { + + private final CellMatcher cellMatcher; + + private CellMatcherStub(CellMatcher cellMatcher) { + this.cellMatcher = cellMatcher; + } + + public CellMatcherStub addFormatter(Formatter formatter) { + formatters.put(this.cellMatcher, formatter); + return this; + } + + public CellMatcherStub addSizer(SizeConstraints sizer) { + sizeConstraints.put(this.cellMatcher, sizer); + return this; + } + + public CellMatcherStub addWrapper(TextWrapper textWrapper) { + wrappers.put(this.cellMatcher, textWrapper); + return this; + } + + public CellMatcherStub addAligner(Aligner aligner) { + aligners.put(this.cellMatcher, aligner); + return this; + } + + public CellMatcherStub on(CellMatcher other) { + return TableBuilder.this.on(other); + } + + public TableBuilder and() { + return TableBuilder.this; + } + + public Table build() { + return TableBuilder.this.build(); + } + } + + public class BorderStub { + + private final BorderStyle style; + + private final int match; + + private BorderStub(BorderStyle style, int match) { + this.style = style; + this.match = match; + } + + public TopLeft fromRowColumn(int row, int column) { + return new TopLeft(row, column); + } + + public TopLeft fromTopLeft() { + return new TopLeft(0, 0); + } + + public class TopLeft { + private final int row; + + private final int column; + + private TopLeft(int row, int column) { + this.row = row; + this.column = column; + } + + public TableBuilder toRowColumn(int row, int column) { + TableBuilder.this.addBorder(TopLeft.this.row, TopLeft.this.column, row, column, BorderStub.this.match, BorderStub.this.style); + return TableBuilder.this; + } + + public TableBuilder toBottomRight() { + return toRowColumn(model.getRowCount(), model.getColumnCount()); + } + } + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/TableModel.java b/spring-shell-table/src/main/java/org/springframework/shell/table/TableModel.java new file mode 100644 index 00000000..381c261d --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/TableModel.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * Abstracts away the contract a {@link Table} will use to retrieve tabular data. + * + * @author Eric Bottard + */ +public abstract class TableModel { + + /** + * @return the number of rows that can be queried. + * Values between 0 and {@code rowCount-1} inclusive are valid values. + */ + public abstract int getRowCount(); + + /** + * @return the number of columns that can be queried. + * Values between 0 and {@code columnCount-1} inclusive are valid values. + */ + public abstract int getColumnCount(); + + /** + * @return the data value to be displayed at a given row and column, which may be null. + * @param row the row that is being queried + * @param column the column that is being queried + */ + public abstract Object getValue(int row, int column); + + /** + * @return a transposed view of this model, where rows become columns and vice-versa. + */ + public TableModel transpose() { + return new TableModel() { + @Override + public int getRowCount() { + return TableModel.this.getColumnCount(); + } + + @Override + public int getColumnCount() { + return TableModel.this.getRowCount(); + } + + @Override + public Object getValue(int row, int column) { + return TableModel.this.getValue(column, row); + } + }; + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/TableModelBuilder.java b/spring-shell-table/src/main/java/org/springframework/shell/table/TableModelBuilder.java new file mode 100644 index 00000000..12c76acc --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/TableModelBuilder.java @@ -0,0 +1,83 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Helper class to build a TableModel incrementally. + * + * @author Eric Bottard + */ +public class TableModelBuilder { + + public static final int DEFAULT_ROW_CAPACITY = 3; + + private List> rows = new ArrayList>(); + + private int previousRowSize = -1; + + private boolean frozen; + + public TableModelBuilder addRow() { + Assert.isTrue(!frozen, "TableModel has already been built, builder can't be altered anymore"); + int nbRows = rows.size(); + if (previousRowSize != -1) { + int currentRowSize = rows.get(nbRows - 1).size(); + Assert.isTrue(currentRowSize == previousRowSize, + "Can't switch to next row, as the current one does not have as many elements as the previous one"); + } + if (rows.size() > 0) { + previousRowSize = rows.get(0).size(); + } + rows.add(new ArrayList(previousRowSize == -1 ? DEFAULT_ROW_CAPACITY : previousRowSize)); + return this; + } + + public TableModelBuilder addValue(T value) { + Assert.isTrue(!frozen, "TableModel has already been built, builder can't be altered anymore"); + if (previousRowSize != -1 && rows.get(rows.size() - 1).size() == previousRowSize) { + throw new IllegalArgumentException("Can't add another value to current row"); + } + rows.get(rows.size() - 1).add(value); + return this; + } + + public TableModel build() { + frozen = true; + return new TableModel() { + @Override + public int getRowCount() { + return rows.size(); + } + + @Override + public int getColumnCount() { + return rows.isEmpty() ? 0 : rows.get(0).size(); + } + + @Override + public Object getValue(int row, int column) { + return rows.get(row).get(column); + } + }; + + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/Tables.java b/spring-shell-table/src/main/java/org/springframework/shell/table/Tables.java new file mode 100644 index 00000000..52289e08 --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/Tables.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +import java.util.Map; + +/** + * Utility class used to create and configure typical Tables. + * + * @author Eric Bottard + */ +public class Tables { + + /** + * Install all the necessary formatters, aligners, etc for key-value rendering of Maps. + * + * @param builder the builder to configure + * @param delimiter the delimiter to apply between keys and values + * @return buider for method chaining + */ + public static TableBuilder configureKeyValueRendering(TableBuilder builder, String delimiter) { + return builder.on(CellMatchers.ofType(Map.class)) + .addFormatter(new MapFormatter(delimiter)) + .addAligner(new KeyValueHorizontalAligner(delimiter.trim())) + .addSizer(new KeyValueSizeConstraints(delimiter)) + .addWrapper(new KeyValueTextWrapper(delimiter)).and(); + } +} diff --git a/spring-shell-table/src/main/java/org/springframework/shell/table/TextWrapper.java b/spring-shell-table/src/main/java/org/springframework/shell/table/TextWrapper.java new file mode 100644 index 00000000..5838174d --- /dev/null +++ b/spring-shell-table/src/main/java/org/springframework/shell/table/TextWrapper.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.shell.table; + +/** + * A strategy for applying text wrapping/cropping given a cell width. + * + * @author Eric Bottard + */ +public interface TextWrapper { + + /** + * @return a list of lines where each line length MUST be equal to {@code columnWidth} (padding with spaces if + * appropriate). There is no constraint on the number of lines returned however (typically, will be greater than + * the input number if wrapping occurred). + * + * @param original the text in its original form + * @param columnWidth the width to conform to + */ + String[] wrap(String[] original, int columnWidth); +}