From 10d99402992f9266c31db2b66325dbb50fb00b7a Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Fri, 2 Feb 2024 08:44:28 +0000 Subject: [PATCH] Initial ProgressView implementation - Provides a basic feature set for ProgressView. - Progress can have a set of items which are driven by function style factory interface. - New class TextCell which is a base of progress items. - Not yet fully working and further changes, samples, docs will come in other commits. - Relates #995 --- .../component/view/control/ProgressView.java | 439 ++++++++++++++++++ .../shell/component/view/control/Spinner.java | 342 ++++++++++++++ .../view/control/cell/AbstractTextCell.java | 82 ++++ .../component/view/control/cell/TextCell.java | 84 ++++ .../view/control/AbstractViewTests.java | 5 +- .../view/control/ProgressViewTests.java | 182 ++++++++ 6 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ProgressView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Spinner.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractTextCell.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/TextCell.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ProgressViewTests.java diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ProgressView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ProgressView.java new file mode 100644 index 000000000..f4a8f914e --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ProgressView.java @@ -0,0 +1,439 @@ +/* + * Copyright 2024 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 + * + * https://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.component.view.control; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import org.springframework.shell.component.message.ShellMessageBuilder; +import org.springframework.shell.component.view.control.cell.TextCell; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.geom.HorizontalAlign; +import org.springframework.shell.geom.Rectangle; +import org.springframework.shell.style.ThemeResolver; +import org.springframework.util.Assert; + +/** + * {@code ProgressView} is used to show a progress indicator. + * + * Defaults to + * + * @author Janne Valkealahti + */ +public class ProgressView extends BoxView { + + private final int tickStart; + private final int tickEnd; + private int tickValue; + private boolean running = false; + private String description; + private Spinner spinner = Spinner.of(Spinner.LINE1, 130); + private int spinnerFrame; + private List items; + private GridView grid; + + private final static Function> DEFAULT_DESCRIPTION_FACTORY = + (item) -> TextCell.of(item, ctx -> { + return ctx.getDescription(); + }); + + private final static Function> DEFAULT_PERCENT_FACTORY = + (item) -> { + TextCell cell = TextCell.of(item, ctx -> { + ProgressState state = ctx.getState(); + int percentAbs = state.tickEnd() - state.tickStart(); + int relativeValue = state.tickValue() - state.tickStart(); + int percent = (relativeValue * 100) / percentAbs; + return String.format("%s%%", percent); + }); + return cell; + }; + + private final static Function> DEFAULT_SPINNER_FACTORY = + item -> { + TextCell cell = TextCell.of(item, ctx -> { + Spinner spin = ctx.spinner(); + int spinState = ctx.getState().sprinnerFrame(); + return String.format("%s", spin.getFrames()[spinState]); + }); + return cell; + }; + + /** + * Construct view with {@code tickStart 0} and {@code tickEnd 100}. + */ + public ProgressView() { + this(0, 100); + } + + /** + * Construct view with given bounds for {@code tickStart} and {@code tickEnd}. + * {@code tickStart} needs to be equal or more than zero. {@code tickEnd} needs + * to be higher than {@code tickStart}. Defines default items for {@code text}, + * {@code spinner} and {@code percent}. + * + * @param tickStart the tick start + * @param tickEnd the tick end + */ + public ProgressView(int tickStart, int tickEnd) { + this(tickStart, tickEnd, new ProgressViewItem[] { ProgressViewItem.ofText(), ProgressViewItem.ofSpinner(), + ProgressViewItem.ofPercent() }); + } + + /** + * Construct view with given bounds for {@code tickStart} and {@code tickEnd}. + * {@code tickStart} needs to be equal or more than zero. {@code tickEnd} needs + * to be higher than {@code tickStart}. Uses defined progress view items. + * + * @param tickStart the tick start + * @param tickEnd the tick end + * @param items the progress view items + */ + public ProgressView(int tickStart, int tickEnd, ProgressViewItem... items) { + Assert.isTrue(tickStart >= 0, "Start tick value must be greater or equal than zero"); + Assert.isTrue(tickEnd > 0, "End tick value must be greater than zero"); + Assert.isTrue(tickEnd > tickStart, "End tick value must be greater than start tick value"); + this.tickStart = tickStart; + this.tickEnd = tickEnd; + this.tickValue = tickStart; + this.items = Arrays.asList(items); + initLayout(); + } + + /** + * Defines an item within a progress view. Allows to set item's factory, size + * and horizontal alignment. + */ + public static class ProgressViewItem { + + private final Function> factory; + private final int size; + private final HorizontalAlign align; + + public ProgressViewItem(Function> factory, int size, HorizontalAlign align) { + this.factory = factory; + this.size = size; + this.align = align; + } + + public static ProgressViewItem ofText() { + return ofText(0, HorizontalAlign.CENTER); + } + + public static ProgressViewItem ofText(int size, HorizontalAlign hAligh) { + return new ProgressViewItem(DEFAULT_DESCRIPTION_FACTORY, size, hAligh); + } + + public static ProgressViewItem ofSpinner() { + return ofSpinner(0, HorizontalAlign.CENTER); + } + + public static ProgressViewItem ofSpinner(int size, HorizontalAlign hAligh) { + return new ProgressViewItem(DEFAULT_SPINNER_FACTORY, size, hAligh); + } + + public static ProgressViewItem ofPercent() { + return ofPercent(0, HorizontalAlign.CENTER); + } + + public static ProgressViewItem ofPercent(int size, HorizontalAlign hAligh) { + return new ProgressViewItem(DEFAULT_PERCENT_FACTORY, size, hAligh); + } + } + + /** + * Gets a progress description. + * + * @return a progress description + */ + public String getDescription() { + return description; + } + + /** + * Sets a progress description. Used in items as a text item. + * + * @param description the progress description + */ + public void setDescription(String description) { + this.description = description; + } + + public void start() { + if (running) { + return; + } + running = true; + ProgressState state = getState(); + dispatch(ShellMessageBuilder.ofView(this, ProgressViewStartEvent.of(this, state))); + } + + public void stop() { + if (!running) { + return; + } + running = false; + ProgressState state = getState(); + dispatch(ShellMessageBuilder.ofView(this, ProgressViewEndEvent.of(this, state))); + } + + private static class BoxWrapper extends BoxView { + TextCell delegate; + BoxWrapper(TextCell delegate) { + this.delegate = delegate; + } + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getRect(); + delegate.setRect(rect.x(), rect.y(), rect.width(), rect.height()); + delegate.draw(screen); + super.drawInternal(screen); + } + } + + private void initLayout() { + grid = new GridView(); + int[] columnSizes = new int[items.size()]; + int index = 0; + for (ProgressViewItem item : items) { + columnSizes[index] = item.size; + TextCell cell = item.factory.apply(buildContext()); + cell.setHorizontalAlign(item.align); + grid.addItem(new BoxWrapper(cell), 0, index, 1, 1, 0, 0); + index++; + } + grid.setRowSize(0); + grid.setColumnSize(columnSizes); + + } + + boolean needLastMillisInit = true; + private long lastMillis; + + @Override + protected void drawInternal(Screen screen) { + long current = System.currentTimeMillis(); + if (needLastMillisInit) { + needLastMillisInit = false; + lastMillis = current; + } + + long elapsedFromLast = current - lastMillis; + if (elapsedFromLast > spinner.getInterval()) { + spinnerFrame = (spinnerFrame + 1) % spinner.getFrames().length; + lastMillis = current; + } + + Rectangle rect = getRect(); + int width = rect.width(); + width = width / 3; + + grid.setRect(rect.x(), rect.y(), rect.width(), rect.height()); + grid.draw(screen); + + super.drawInternal(screen); + } + + /** + * Advance {@code tickValue} by a given count. Note that negative count + * will advance backwards. + * + * @param count the count to advance tick value + */ + public void tickAdvance(int count) { + setTickValue(tickValue + count); + } + + /** + * Sets a tick value. If value is lower or higher than {@code tickStart} or + * {@code tickEnd} respectively {@code tickValue} will be set to low/high + * bounds. This means {@code tickValue} is always kept within range inclusively. + * + * @param value the new tick value to set + */ + public void setTickValue(int value) { + boolean changed = false; + if (value > tickEnd) { + changed = tickValue != tickEnd; + tickValue = tickEnd; + } + else if (value < tickStart) { + changed = tickValue != tickStart; + tickValue = tickStart; + } + else { + changed = tickValue != value; + tickValue = value; + } + if (changed) { + ProgressState state = getState(); + dispatch(ShellMessageBuilder.ofView(this, ProgressViewStateChangeEvent.of(this, state))); + } + } + + /** + * Gets a state of this {@code ProgressView}. + * + * @return a view progress state + */ + public ProgressState getState() { + return ProgressState.of(tickStart, tickEnd, tickValue, running, spinnerFrame); + } + + private Context buildContext() { + return new Context() { + + @Override + public String getDescription() { + return ProgressView.this.getDescription(); + } + + @Override + public ProgressState getState() { + return ProgressView.this.getState(); + } + + @Override + public ProgressView getView() { + return ProgressView.this; + } + + @Override + public int resolveThemeStyle(String tag, int defaultStyle) { + return ProgressView.this.resolveThemeStyle(tag, defaultStyle); + } + + @Override + public Spinner spinner() { + return ProgressView.this.spinner; + } + }; + } + + /** + * Context for {@code ProgressView} cell components. + */ + public interface Context { + + /** + * Get a {@link ProgressView} description. + * + * @return a progress description + */ + String getDescription(); + + /** + * Get a state of a {@link ProgressView}. + * + * @return progress view state + */ + ProgressState getState(); + + /** + * Gets an encapsulating owner view. + * + * @return an owner view + */ + ProgressView getView(); + + /** + * Gets a {@link Spinner} frames. + * + * @return spinner frames + */ + Spinner spinner(); + + /** + * Resolve style using existing {@link ThemeResolver} and {@code theme name}. + * Use {@code defaultStyle} if resolving cannot happen. + * + * @param tag the style tag to use + * @param defaultStyle the default style to use + * @return resolved style + */ + int resolveThemeStyle(String tag, int defaultStyle); + + } + + /** + * Encapsulates a current running state of a {@link ProgressView}. + * + * @param tickStart the tick start value, zero or positive + * @param tickEnd the tick end value, positive and more than tick start + * @param tickValue the current tick value, within inclusive bounds of tick start/end + * @param running the running state + * @param spinnerFrame the current spinner frame index + */ + public record ProgressState(int tickStart, int tickEnd, int tickValue, boolean running, int sprinnerFrame) { + + public static ProgressState of(int tickStart, int tickEnd, int tickValue, boolean running, int spinnerFrame) { + return new ProgressState(tickStart, tickEnd, tickValue, running, spinnerFrame); + } + } + + /** + * {@link ViewEventArgs} for events using {@link ProgressState}. + * + * @param state the progress state + */ + public record ProgressViewStateEventArgs(ProgressState state) implements ViewEventArgs { + + public static ProgressViewStateEventArgs of(ProgressState state) { + return new ProgressViewStateEventArgs(state); + } + } + + /** + * {@link ViewEvent} indicating that proggress has been started. + * + * @param view the view sending an event + * @param args the event args + */ + public record ProgressViewStartEvent(View view, ProgressViewStateEventArgs args) implements ViewEvent { + + public static ProgressViewStartEvent of(View view, ProgressState state) { + return new ProgressViewStartEvent(view, ProgressViewStateEventArgs.of(state)); + } + } + + /** + * {@link ViewEvent} indicating that proggress has been ended. + * + * @param view the view sending an event + * @param args the event args + */ + public record ProgressViewEndEvent(View view, ProgressViewStateEventArgs args) implements ViewEvent { + + public static ProgressViewEndEvent of(View view, ProgressState state) { + return new ProgressViewEndEvent(view, ProgressViewStateEventArgs.of(state)); + } + } + + /** + * {@link ViewEvent} indicating that proggress state has been changed. + * + * @param view the view sending an event + * @param args the event args + */ + public record ProgressViewStateChangeEvent(View view, ProgressViewStateEventArgs args) implements ViewEvent { + + public static ProgressViewStateChangeEvent of(View view, ProgressState state) { + return new ProgressViewStateChangeEvent(view, ProgressViewStateEventArgs.of(state)); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Spinner.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Spinner.java new file mode 100644 index 000000000..9c2dbab8c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Spinner.java @@ -0,0 +1,342 @@ +/* + * Copyright 2024 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 + * + * https://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.component.view.control; + +/** + * {@code Spinner} represents how user is notified that something is happening + * using a traditional spinner concept. Represented in a console with an array + * of characters which are looped. + * + * @author Janne Valkealahti + */ +public interface Spinner { + + /** + * Gets a frame characters. Type is {@link String} to support unicode. + * + * @return a frame characters + */ + String[] getFrames(); + + /** + * Gets an interval which should be used to estimate how often frame + * should get changed. This is always an estimate as actual change + * depends how ofter console gets redrawn. + * + * @return an interval in milliseconds + */ + int getInterval(); + + /** + * Construct {@link Spinner} from given frames and interval. + * + * @param frames the spinner frames + * @param interval the spinner interval + * @return a Spinner implementation + */ + static Spinner of(String[] frames, int interval) { + return new Spinner() { + + @Override + public String[] getFrames() { + return frames; + } + + @Override + public int getInterval() { + return interval; + } + + }; + } + + final static String[] LINE1 = new String[] { + "-", + "\\", + "|", + "/" + }; + + final static String[] DOTS1 = new String[] { + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏" + }; + + final static String[] DOTS2 = new String[] { + "⣾", + "⣽", + "⣻", + "⢿", + "⡿", + "⣟", + "⣯", + "⣷" + }; + + final static String[] DOTS3 = new String[] { + "⠋", + "⠙", + "⠚", + "⠞", + "⠖", + "⠦", + "⠴", + "⠲", + "⠳", + "⠓" + }; + + final static String[] DOTS4 = new String[] { + "⠄", + "⠆", + "⠇", + "⠋", + "⠙", + "⠸", + "⠰", + "⠠", + "⠰", + "⠸", + "⠙", + "⠋", + "⠇", + "⠆" + }; + + final static String[] DOTS5 = new String[] { + "⠋", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋" + }; + + final static String[] DOTS6 = new String[] { + "⠁", + "⠉", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠤", + "⠄", + "⠄", + "⠤", + "⠴", + "⠲", + "⠒", + "⠂", + "⠂", + "⠒", + "⠚", + "⠙", + "⠉", + "⠁" + }; + + final static String[] DOTS7 = new String[] { + "⠈", + "⠉", + "⠋", + "⠓", + "⠒", + "⠐", + "⠐", + "⠒", + "⠖", + "⠦", + "⠤", + "⠠", + "⠠", + "⠤", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋", + "⠉", + "⠈" + }; + + final static String[] DOTS8 = new String[] { + "⠁", + "⠁", + "⠉", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠤", + "⠄", + "⠄", + "⠤", + "⠠", + "⠠", + "⠤", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋", + "⠉", + "⠈", + "⠈" + }; + + final static String[] DOTS9 = new String[] { + "⢹", + "⢺", + "⢼", + "⣸", + "⣇", + "⡧", + "⡗", + "⡏" + }; + + final static String[] DOTS10 = new String[] { + "⢄", + "⢂", + "⢁", + "⡁", + "⡈", + "⡐", + "⡠" + }; + + final static String[] DOTS11 = new String[] { + "⠁", + "⠂", + "⠄", + "⡀", + "⢀", + "⠠", + "⠐", + "⠈" + }; + + final static String[] DOTS12 = new String[] { + "⢀⠀", + "⡀⠀", + "⠄⠀", + "⢂⠀", + "⡂⠀", + "⠅⠀", + "⢃⠀", + "⡃⠀", + "⠍⠀", + "⢋⠀", + "⡋⠀", + "⠍⠁", + "⢋⠁", + "⡋⠁", + "⠍⠉", + "⠋⠉", + "⠋⠉", + "⠉⠙", + "⠉⠙", + "⠉⠩", + "⠈⢙", + "⠈⡙", + "⢈⠩", + "⡀⢙", + "⠄⡙", + "⢂⠩", + "⡂⢘", + "⠅⡘", + "⢃⠨", + "⡃⢐", + "⠍⡐", + "⢋⠠", + "⡋⢀", + "⠍⡁", + "⢋⠁", + "⡋⠁", + "⠍⠉", + "⠋⠉", + "⠋⠉", + "⠉⠙", + "⠉⠙", + "⠉⠩", + "⠈⢙", + "⠈⡙", + "⠈⠩", + "⠀⢙", + "⠀⡙", + "⠀⠩", + "⠀⢘", + "⠀⡘", + "⠀⠨", + "⠀⢐", + "⠀⡐", + "⠀⠠", + "⠀⢀", + "⠀⡀" + }; + + final static String[] DOTS13 = new String[] { + "⣼", + "⣹", + "⢻", + "⠿", + "⡟", + "⣏", + "⣧", + "⣶" + }; + + final static String[] DOTS14 = new String[] { + ". ", + " . ", + " . ", + " ." + }; + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractTextCell.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractTextCell.java new file mode 100644 index 000000000..bbcc207b1 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractTextCell.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 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 + * + * https://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.component.view.control.cell; + +import java.util.function.Function; + +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.shell.geom.HorizontalAlign; +import org.springframework.shell.geom.Rectangle; +import org.springframework.shell.geom.VerticalAlign; +import org.springframework.shell.style.StyleSettings; + +/** + * Base implementation of a {@link TextCell}. + * + * @author Janne Valkealahti + */ +public abstract class AbstractTextCell extends AbstractCell implements TextCell { + + private Function itemFunction; + private HorizontalAlign hAlign = HorizontalAlign.CENTER; + private VerticalAlign vAlign = VerticalAlign.CENTER; + + public AbstractTextCell(T item, Function itemFunction) { + this(item, itemFunction, HorizontalAlign.CENTER, VerticalAlign.CENTER); + } + + public AbstractTextCell(T item, Function itemFunction, HorizontalAlign hAlign, VerticalAlign vAlign) { + super(item); + this.itemFunction = itemFunction; + this.hAlign = hAlign; + this.vAlign = vAlign; + } + + @Override + public void setHorizontalAlign(HorizontalAlign hAlign) { + this.hAlign = hAlign; + } + + @Override + public void setVerticalAlign(VerticalAlign vAlign) { + this.vAlign = vAlign; + } + + protected String getBackgroundStyle() { + return StyleSettings.TAG_BACKGROUND; + } + + @Override + protected void drawBackground(Screen screen) { + Rectangle rect = getRect(); + int bgColor = resolveThemeBackground(getBackgroundStyle(), getBackgroundColor(), -1); + if (bgColor > -1) { + screen.writerBuilder().build().background(rect, bgColor); + } + } + + @Override + protected void drawContent(Screen screen) { + String text = itemFunction.apply(getItem()); + if (text != null) { + Rectangle rect = getRect(); + Writer writer = screen.writerBuilder().style(getStyle()).color(getForegroundColor()).build(); + writer.text(text, rect, hAlign, vAlign); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/TextCell.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/TextCell.java new file mode 100644 index 000000000..3b7b151db --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/TextCell.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 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 + * + * https://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.component.view.control.cell; + +import java.util.function.Function; + +import org.springframework.shell.geom.HorizontalAlign; +import org.springframework.shell.geom.VerticalAlign; + +/** + * Extension of a {@link Cell} to make it aware of an item style and selection state. + * + * @author Janne Valkealahti + */ +public interface TextCell extends Cell { + + /** + * Sets horizontal align for a text to draw. Defaults to + * {@link HorizontalAlign#CENTER}. + * + * @param hAlign the horizontal align + */ + void setHorizontalAlign(HorizontalAlign hAlign); + + /** + * Sets vertical align for a text to draw. Defaults to + * {@link VerticalAlign#CENTER}. + * + * @param vAlign the vertical align + */ + void setVerticalAlign(VerticalAlign vAlign); + + /** + * Helper method to build a {@code TextCell}. + * + * @param type of an item + * @param item the item + * @param itemFunction the item function + * @return a default text cell + */ + static TextCell of(T item, Function itemFunction) { + return new DefaultTextCell(item, itemFunction); + } + + /** + * Helper method to build a {@code TextCell}. + * + * @param type of an item + * @param item the item + * @param itemFunction the item function + * @param hAlign item horizontal alignment + * @param vAlign item vertical alignment + * @return + */ + static TextCell of(T item, Function itemFunction, HorizontalAlign hAlign, VerticalAlign vAlign) { + return new DefaultTextCell(item, itemFunction, hAlign, vAlign); + } + + static class DefaultTextCell extends AbstractTextCell { + + DefaultTextCell(T item, Function itemFunction) { + super(item, itemFunction); + } + + DefaultTextCell(T item, Function itemFunction, HorizontalAlign hAlign, VerticalAlign vAlign) { + super(item, itemFunction, hAlign, vAlign); + } + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java index afbd89805..4f1e042d0 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-2024 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. @@ -36,6 +36,7 @@ public class AbstractViewTests { protected Screen screen24x80; + protected Screen screen1x80; protected Screen screen7x10; protected Screen screen10x10; protected Screen screen0x0; @@ -44,6 +45,7 @@ public class AbstractViewTests { @BeforeEach void setup() { screen24x80 = new DefaultScreen(24, 80); + screen1x80 = new DefaultScreen(1, 80); screen7x10 = new DefaultScreen(7, 10); screen0x0 = new DefaultScreen(); screen10x10 = new DefaultScreen(10, 10); @@ -60,6 +62,7 @@ void cleanup() { protected void clearScreens() { screen24x80.resize(24, 80); + screen1x80.resize(1, 80); screen7x10.resize(7, 10); screen0x0.resize(0, 0); screen10x10.resize(10, 10); diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ProgressViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ProgressViewTests.java new file mode 100644 index 000000000..f8680f75c --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ProgressViewTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2024 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 + * + * https://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.component.view.control; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.shell.component.view.control.ProgressView.ProgressViewEndEvent; +import org.springframework.shell.component.view.control.ProgressView.ProgressViewItem; +import org.springframework.shell.component.view.control.ProgressView.ProgressViewStartEvent; +import org.springframework.shell.component.view.control.ProgressView.ProgressViewStateChangeEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProgressViewTests extends AbstractViewTests { + + @Nested + class Construction { + + ProgressView view; + + @Test + void constructDefault() { + view = new ProgressView(); + assertThat(view.getState().tickValue()).isEqualTo(0); + } + + @Test + void constructBounds() { + view = new ProgressView(10, 30); + assertThat(view.getState().tickValue()).isEqualTo(10); + assertThat(view.getState().tickStart()).isEqualTo(10); + assertThat(view.getState().tickEnd()).isEqualTo(30); + } + + void xxx() { + view = new ProgressView(10, 30, ProgressViewItem.ofText()); + ProgressViewItem.ofText(); + } + + } + + @Nested + class Visual { + + ProgressView view; + + @BeforeEach + void setup() { + view = new ProgressView(); + view.setDescription("name"); + view.setRect(0, 0, 80, 1); + configure(view); + } + + @Test + void defaultItems() { + view.draw(screen1x80); + assertThat(forScreen(screen1x80)).hasHorizontalText("name", 11, 0, 4); + assertThat(forScreen(screen1x80)).hasHorizontalText("-", 39, 0, 1); + assertThat(forScreen(screen1x80)).hasHorizontalText("0%", 65, 0, 2); + } + + @Test + void advance() { + view.draw(screen1x80); + assertThat(forScreen(screen1x80)).hasHorizontalText("0%", 65, 0, 2); + view.tickAdvance(5); + view.draw(screen1x80); + assertThat(forScreen(screen1x80)).hasHorizontalText("5%", 65, 0, 2); + view.tickAdvance(5); + view.draw(screen1x80); + assertThat(forScreen(screen1x80)).hasHorizontalText("10%", 65, 0, 3); + } + + } + + @Nested + class State { + + ProgressView view; + + @BeforeEach + void setup() { + view = new ProgressView(); + view.setRect(0, 0, 20, 1); + configure(view); + } + + @Test + void dontAdvanceBounds() { + assertThat(view.getState().tickValue()).isEqualTo(0); + view.tickAdvance(-1); + assertThat(view.getState().tickValue()).isEqualTo(0); + view.tickAdvance(100); + assertThat(view.getState().tickValue()).isEqualTo(100); + view.tickAdvance(1); + assertThat(view.getState().tickValue()).isEqualTo(100); + } + + } + + @Nested + class Events { + + ProgressView view; + + @BeforeEach + void setup() { + view = new ProgressView(); + view.setRect(0, 0, 20, 1); + configure(view); + } + + @Test + void plainStartStop() { + Flux startEvents = eventLoop.viewEvents(ProgressViewStartEvent.class); + Flux endEvents = eventLoop.viewEvents(ProgressViewEndEvent.class); + + StepVerifier startVerifier = StepVerifier.create(startEvents) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + StepVerifier endVerifier = StepVerifier.create(endEvents) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + view.start(); + view.stop(); + startVerifier.verify(Duration.ofSeconds(1)); + endVerifier.verify(Duration.ofSeconds(1)); + } + + @Test + void stateChangeWithTickValue() { + Flux changeEvents = eventLoop.viewEvents(ProgressViewStateChangeEvent.class); + StepVerifier verifier = StepVerifier.create(changeEvents) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + view.setTickValue(1); + verifier.verify(Duration.ofSeconds(1)); + } + + } + + @Nested + class Styling { + + @Test + void hasBorder() { + ProgressView view = new ProgressView(); + configure(view); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + + } + +}