From a0bdbfc033b70b2617de977d7f69d4a1cf1ed8ba Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Sat, 15 Jul 2023 16:42:39 +0100 Subject: [PATCH] Initial terminal ui implementation - This commit adds proof of concept work for terminal ui as is. - Some things work, some don't but we need to start from somewhere. Further development continues in a main. - Essentially we are starting to have enough so that it merits to move all this work into a main repo. - Everything new is kept under org.springframework.shell.component.view and will get revisiter later to find correct locations for some classes. - Catalog sample has been modified to provide "showcase" app for terminal ui features. This is a start while it already contains some usefull scenarios. - Relates #800 - Relates #801 - Relates #802 - Relates #803 - Relates #804 - Relates #805 - Relates #806 - Relates #807 - Relates #808 - Relates #809 - Relates #810 - Relates #811 --- .vscode/launch.json | 7 + .vscode/settings.json | 3 +- spring-shell-core/spring-shell-core.gradle | 2 + .../shell/component/view/TerminalUI.java | 365 ++++++++++++ .../component/view/control/AbstractView.java | 398 +++++++++++++ .../shell/component/view/control/AppView.java | 136 +++++ .../shell/component/view/control/BoxView.java | 239 ++++++++ .../shell/component/view/control/Cell.java | 79 +++ .../shell/component/view/control/Control.java | 54 ++ .../component/view/control/GridView.java | 541 ++++++++++++++++++ .../component/view/control/InputView.java | 103 ++++ .../component/view/control/ListView.java | 192 +++++++ .../component/view/control/MenuBarView.java | 274 +++++++++ .../component/view/control/MenuView.java | 474 +++++++++++++++ .../component/view/control/StatusBarView.java | 185 ++++++ .../shell/component/view/control/View.java | 72 +++ .../component/view/control/ViewCommand.java | 50 ++ .../component/view/control/ViewEvent.java | 30 + .../component/view/control/ViewEventArgs.java | 27 + .../view/control/cell/AbstractCell.java | 74 +++ .../view/control/cell/AbstractControl.java | 46 ++ .../component/view/control/cell/ListCell.java | 46 ++ .../view/event/DefaultEventLoop.java | 291 ++++++++++ .../shell/component/view/event/EventLoop.java | 211 +++++++ .../shell/component/view/event/KeyBinder.java | 81 +++ .../view/event/KeyBindingConsumer.java | 22 + .../view/event/KeyBindingConsumerArgs.java | 19 + .../shell/component/view/event/KeyEvent.java | 129 +++++ .../component/view/event/KeyHandler.java | 131 +++++ .../view/event/MouseBindingConsumer.java | 22 + .../view/event/MouseBindingConsumerArgs.java | 19 + .../component/view/event/MouseEvent.java | 113 ++++ .../component/view/event/MouseHandler.java | 135 +++++ .../AnimationEventLoopProcessor.java | 61 ++ .../processor/TaskEventLoopProcessor.java | 91 +++ .../shell/component/view/geom/Dimension.java | 22 + .../component/view/geom/HorizontalAlign.java | 20 + .../shell/component/view/geom/Position.java | 22 + .../shell/component/view/geom/Rectangle.java | 39 ++ .../component/view/geom/VerticalAlign.java | 20 + .../view/message/ShellMessageBuilder.java | 121 ++++ .../message/ShellMessageHeaderAccessor.java | 155 +++++ .../StaticShellMessageHeaderAccessor.java | 98 ++++ .../shell/component/view/screen/Color.java | 443 ++++++++++++++ .../component/view/screen/DefaultScreen.java | 411 +++++++++++++ .../component/view/screen/DisplayLines.java | 37 ++ .../shell/component/view/screen/Screen.java | 164 ++++++ .../component/view/screen/ScreenItem.java | 49 ++ .../shell/component/view/ScreenAssert.java | 326 +++++++++++ .../component/view/ScreenAssertTests.java | 162 ++++++ .../view/control/AbstractViewTests.java | 125 ++++ .../component/view/control/BoxViewTests.java | 100 ++++ .../component/view/control/GridViewTests.java | 165 ++++++ .../view/control/InputViewTests.java | 51 ++ .../component/view/control/ListViewTests.java | 128 +++++ .../view/control/MenuBarViewTests.java | 215 +++++++ .../component/view/control/MenuViewTests.java | 350 +++++++++++ .../view/control/StatusBarViewTests.java | 135 +++++ .../view/control/cell/ListCellTests.java | 67 +++ .../view/event/DefaultEventLoopTests.java | 193 +++++++ .../component/view/event/KeyEventTests.java | 56 ++ .../component/view/event/KeyHandlerTests.java | 76 +++ .../component/view/event/MouseEventTests.java | 100 ++++ .../view/event/MouseHandlerTests.java | 76 +++ .../component/view/screen/ScreenTests.java | 101 ++++ .../main/asciidoc/appendices-tui-catalog.adoc | 12 + .../main/asciidoc/appendices-tui-control.adoc | 5 + .../asciidoc/appendices-tui-eventloop.adoc | 5 + .../asciidoc/appendices-tui-keyhandling.adoc | 5 + .../appendices-tui-mousehandling.adoc | 5 + .../main/asciidoc/appendices-tui-screen.adoc | 6 + .../main/asciidoc/appendices-tui-view.adoc | 5 + .../src/main/asciidoc/appendices-tui.adoc | 22 + .../src/main/asciidoc/appendices.adoc | 2 + .../main/asciidoc/using-shell-tui-intro.adoc | 14 + .../asciidoc/using-shell-tui-views-box.adoc | 6 + .../asciidoc/using-shell-tui-views-list.adoc | 5 + .../main/asciidoc/using-shell-tui-views.adoc | 11 + .../src/main/asciidoc/using-shell-tui.adoc | 16 + .../src/main/asciidoc/using-shell.adoc | 2 + .../shell/docs/TerminalUiSnippets.java | 49 ++ .../shell/samples/catalog/Catalog.java | 252 ++++++++ .../shell/samples/catalog/CatalogCommand.java | 27 +- .../catalog/SpringShellApplication.java | 2 + .../catalog/scenario/AbstractScenario.java | 39 ++ .../samples/catalog/scenario/Scenario.java | 50 ++ .../catalog/scenario/ScenarioComponent.java | 61 ++ .../scenario/box/DrawFunctionScenario.java | 39 ++ .../scenario/box/SimpleBoxViewScenario.java | 42 ++ .../scenario/grid/SimpleGridViewScenario.java | 78 +++ .../listview/SimpleListViewScenario.java | 37 ++ .../catalog/scenario/other/ClockScenario.java | 144 +++++ .../other/SimpleInputViewScenario.java | 36 ++ .../scenario/other/SnakeGameScenario.java | 272 +++++++++ 94 files changed, 9990 insertions(+), 8 deletions(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/TerminalUI.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AbstractView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/BoxView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Cell.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Control.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/InputView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ListView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuBarView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/StatusBarView.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/View.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewCommand.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEvent.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEventArgs.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractCell.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractControl.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/ListCell.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/DefaultEventLoop.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/EventLoop.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumer.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumerArgs.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyHandler.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumer.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumerArgs.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseEvent.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseHandler.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/AnimationEventLoopProcessor.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/TaskEventLoopProcessor.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Dimension.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/HorizontalAlign.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Position.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Rectangle.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/VerticalAlign.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageBuilder.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageHeaderAccessor.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/message/StaticShellMessageHeaderAccessor.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Color.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DefaultScreen.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DisplayLines.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Screen.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/ScreenItem.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssert.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssertTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BoxViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/InputViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ListViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuBarViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/StatusBarViewTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/cell/ListCellTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/event/DefaultEventLoopTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyEventTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyHandlerTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseEventTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseHandlerTests.java create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/screen/ScreenTests.java create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-catalog.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-control.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-eventloop.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-keyhandling.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-mousehandling.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-screen.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui-view.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/appendices-tui.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-tui-intro.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-tui-views-box.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-tui-views-list.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-tui-views.adoc create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-tui.adoc create mode 100644 spring-shell-docs/src/test/java/org/springframework/shell/docs/TerminalUiSnippets.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/AbstractScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/Scenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/ScenarioComponent.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/DrawFunctionScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/SimpleBoxViewScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/grid/SimpleGridViewScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/listview/SimpleListViewScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/ClockScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SimpleInputViewScenario.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SnakeGameScenario.java diff --git a/.vscode/launch.json b/.vscode/launch.json index f263be933..0b929684e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -94,6 +94,13 @@ "mainClass": "org.springframework.shell.samples.SpringShellSample", "projectName": "spring-shell-sample-e2e", "args": "e2e reg exit-code --arg1 fun" + }, + { + "type": "java", + "name": "catalog", + "request": "launch", + "mainClass": "org.springframework.shell.samples.catalog.SpringShellApplication", + "projectName": "spring-shell-sample-catalog" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 189b68695..0c8777c18 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,6 @@ "checkstyle.header.file": "https://raw.githubusercontent.com/spring-cloud/spring-cloud-dataflow-build/main/spring-cloud-dataflow-build-tools/src/main/resources/checkstyle-header.txt" }, "java.checkstyle.version": "8.29", - "java.configuration.maven.notCoveredPluginExecutionSeverity": "ignore" + "java.configuration.maven.notCoveredPluginExecutionSeverity": "ignore", + "java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx2G -Xms100m -Xlog:disable" } \ No newline at end of file diff --git a/spring-shell-core/spring-shell-core.gradle b/spring-shell-core/spring-shell-core.gradle index b265c03b4..295259905 100644 --- a/spring-shell-core/spring-shell-core.gradle +++ b/spring-shell-core/spring-shell-core.gradle @@ -10,6 +10,7 @@ dependencies { api('org.springframework:spring-core') api('org.springframework.boot:spring-boot-starter-validation') api('org.springframework:spring-messaging') + api('io.projectreactor:reactor-core') api('org.jline:jline') api('org.antlr:ST4') api('commons-io:commons-io') @@ -17,4 +18,5 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.awaitility:awaitility' testImplementation 'com.google.jimfs:jimfs' + testImplementation 'io.projectreactor:reactor-test' } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/TerminalUI.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/TerminalUI.java new file mode 100644 index 000000000..1593a4988 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/TerminalUI.java @@ -0,0 +1,365 @@ +/* + * Copyright 2023 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; + +import java.io.IOError; +import java.util.Collections; +import java.util.List; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Attributes; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.Terminal.Signal; +import org.jline.utils.AttributedString; +import org.jline.utils.Display; +import org.jline.utils.InfoCmp.Capability; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.DefaultEventLoop; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.KeyBinder; +import org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.KeyHandler.KeyHandlerResult; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.message.ShellMessageHeaderAccessor; +import org.springframework.shell.component.view.screen.DefaultScreen; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * {@link TerminalUI} is a main component orchestrating terminal, eventloop, + * key/mouse events and view structure to work together. In many ways it can + * be think of being a "main application" when terminal ui is shown in + * a screen. + * + * @author Janne Valkealahti + */ +public class TerminalUI { + + private final static Logger log = LoggerFactory.getLogger(TerminalUI.class); + private final Terminal terminal; + private final BindingReader bindingReader; + private final KeyMap keyMap = new KeyMap<>(); + private final DefaultScreen virtualDisplay = new DefaultScreen(); + private Display display; + private Size size; + private View rootView; + private boolean fullScreen; + private final KeyBinder keyBinder; + private DefaultEventLoop eventLoop = new DefaultEventLoop(); + private View focus = null; + + /** + * Constructs a handler with a given terminal. + * + * @param terminal the terminal + */ + public TerminalUI(Terminal terminal) { + Assert.notNull(terminal, "terminal must be set"); + this.terminal = terminal; + this.bindingReader = new BindingReader(terminal.reader()); + this.keyBinder = new KeyBinder(terminal); + } + + /** + * Sets a root view. + * + * @param root the root view + * @param fullScreen if root view should request full screen + */ + public void setRoot(View root, boolean fullScreen) { + setFocus(root); + this.rootView = root; + this.fullScreen = fullScreen; + } + + /** + * Run and start execution loop. This method blocks until run loop exits. + */ + public void run() { + bindKeyMap(keyMap); + display = new Display(terminal, fullScreen); + size = new Size(); + loop(); + } + + /** + * Gets an {@link EventLoop}. + * + * @return an event loop + */ + public EventLoop getEventLoop() { + return eventLoop; + } + + /** + * Redraw a whole screen. Essentially a message is dispatched to an event loop + * which is handled as soon as possible. + */ + public void redraw() { + getEventLoop().dispatch(ShellMessageBuilder.ofRedraw()); + } + + public void setFocus(@Nullable View view) { + if (focus != null) { + focus.focus(focus, false); + } + focus = view; + if (focus != null) { + focus.focus(focus, true); + } + } + + private void render(int rows, int columns) { + if (rootView == null) { + return; + } + rootView.setRect(0, 0, columns, rows); + rootView.draw(virtualDisplay); + } + + private void display() { + log.trace("display()"); + size.copy(terminal.getSize()); + if (fullScreen) { + display.clear(); + display.resize(size.getRows(), size.getColumns()); + display.reset(); + rootView.setRect(0, 0, size.getColumns(), size.getRows()); + virtualDisplay.resize(size.getRows(), size.getColumns()); + virtualDisplay.setShowCursor(false); + render(size.getRows(), size.getColumns()); + } + else { + display.resize(size.getRows(), size.getColumns()); + Rectangle rect = rootView.getRect(); + virtualDisplay.resize(rect.height(), rect.width()); + virtualDisplay.setShowCursor(false); + render(rect.height(), rect.width()); + } + + List newLines = virtualDisplay.getScreenLines(); + + int targetCursorPos = 0; + if (virtualDisplay.isShowCursor()) { + terminal.puts(Capability.cursor_visible); + targetCursorPos = size.cursorPos(virtualDisplay.getCursorPosition().y(), virtualDisplay.getCursorPosition().x()); + log.debug("Display targetCursorPos {}", targetCursorPos); + } + else { + terminal.puts(Capability.cursor_invisible); + } + display.update(newLines, targetCursorPos); + } + + private void dispatchWinch() { + Message message = MessageBuilder.withPayload("WINCH") + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.SIGNAL) + .build(); + eventLoop.dispatch(message); + } + + private void registerEventHandling() { + // XXX: think this again + eventLoop.onDestroy(eventLoop.events() + .filter(m -> { + return ObjectUtils.nullSafeEquals(m.getHeaders().get(ShellMessageHeaderAccessor.EVENT_TYPE), EventLoop.Type.SIGNAL); + }) + .doOnNext(m -> { + display(); + }) + .subscribe()); + + // XXX: think this again + eventLoop.onDestroy(eventLoop.events() + .filter(m -> { + return ObjectUtils.nullSafeEquals(m.getHeaders().get(ShellMessageHeaderAccessor.EVENT_TYPE), EventLoop.Type.SYSTEM); + }) + .doOnNext(m -> { + Object payload = m.getPayload(); + if (payload instanceof String s) { + if ("redraw".equals(s)) { + display(); + } + else if ("int".equals(s)) { + this.terminal.raise(Signal.INT); + } + } + }) + .subscribe()); + + eventLoop.onDestroy(eventLoop.keyEvents() + .doOnNext(m -> { + handleKeyEvent(m); + }) + .subscribe()); + + eventLoop.onDestroy(eventLoop.mouseEvents() + .doOnNext(m -> { + handleMouseEvent(m); + }) + .subscribe()); + } + + private void handleKeyEvent(KeyEvent event) { + log.trace("handleKeyEvent {}", event); + if (rootView != null && rootView.hasFocus()) { + KeyHandler handler = rootView.getKeyHandler(); + if (handler != null) { + KeyHandlerResult result = handler.handle(KeyHandler.argsOf(event)); + if (result.focus() != null) { + setFocus(result.focus()); + } + } + } + } + + private void handleMouseEvent(MouseEvent event) { + log.trace("handleMouseEvent {}", event); + if (rootView != null) { + MouseHandler handler = rootView.getMouseHandler(); + if (handler != null) { + MouseHandlerResult result = handler.handle(MouseHandler.argsOf(event)); + if (result.focus() != null) { + setFocus(result.focus()); + } + } + } + } + + private void loop() { + Attributes attr = terminal.enterRawMode(); + registerEventHandling(); + + terminal.handle(Signal.WINCH, signal -> { + log.debug("Handling signal {}", signal); + dispatchWinch(); + }); + + try { + if (fullScreen) { + terminal.puts(Capability.enter_ca_mode); + } + terminal.puts(Capability.keypad_xmit); + terminal.puts(Capability.cursor_invisible); + terminal.trackMouse(Terminal.MouseTracking.Normal); + terminal.writer().flush(); + size.copy(terminal.getSize()); + display.clear(); + display.reset(); + + while (true) { + display(); + boolean exit = read(bindingReader, keyMap); + if (exit) { + break; + } + } + } + finally { + eventLoop.destroy(); + terminal.setAttributes(attr); + log.debug("Setting cursor visible"); + terminal.puts(Capability.cursor_visible); + if (fullScreen) { + display.update(Collections.emptyList(), 0); + } + terminal.trackMouse(Terminal.MouseTracking.Off); + if (fullScreen) { + terminal.puts(Capability.exit_ca_mode); + } + terminal.puts(Capability.keypad_local); + if (!fullScreen) { + display.update(Collections.emptyList(), 0); + } + } + } + + private void bindKeyMap(KeyMap keyMap) { + keyBinder.bindAll(keyMap); + } + + private boolean read(BindingReader bindingReader, KeyMap keyMap) { + Thread readThread = Thread.currentThread(); + terminal.handle(Signal.INT, signal -> { + log.debug("Handling signal {}", signal); + readThread.interrupt(); + }); + + Integer operation = null; + try { + operation = bindingReader.readBinding(keyMap); + log.debug("Read got operation {}", operation); + } catch (IOError e) { + // Ignore Ctrl+C interrupts and just exit the loop + log.trace("Read binding error {}", e); + } + if (operation == null) { + return true; + } + + if (operation == KeyEvent.Key.Char) { + String lastBinding = bindingReader.getLastBinding(); + if (StringUtils.hasLength(lastBinding)) { + dispatchKeyEvent(KeyEvent.of(lastBinding.charAt(0))); + } + } + else if (operation == KeyEvent.Key.Unicode) { + String lastBinding = bindingReader.getLastBinding(); + if (StringUtils.hasLength(lastBinding)) { + dispatchKeyEvent(KeyEvent.of(lastBinding.charAt(0))); + } + } + else if (operation == KeyEvent.Key.Mouse) { + mouseEvent(); + } + else { + dispatchKeyEvent(KeyEvent.of(operation)); + } + + return false; + } + + private void dispatchKeyEvent(KeyEvent event) { + Message message = MessageBuilder + .withPayload(event) + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.KEY) + .build(); + eventLoop.dispatch(message); + } + + private void dispatchMouse(MouseEvent event) { + log.debug("Dispatch mouse event: {}", event); + eventLoop.dispatch(ShellMessageBuilder.ofMouseEvent(event)); + } + + private void mouseEvent() { + dispatchMouse(MouseEvent.of(terminal.readMouseEvent())); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AbstractView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AbstractView.java new file mode 100644 index 000000000..60def015f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AbstractView.java @@ -0,0 +1,398 @@ +/* + * Copyright 2023 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.HashMap; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Predicate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.KeyBindingConsumer; +import org.springframework.shell.component.view.event.KeyBindingConsumerArgs; +import org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseBindingConsumerArgs; +import org.springframework.shell.component.view.event.MouseBindingConsumer; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; + +/** + * Base implementation of a {@link View} and its parent interface + * {@link Control} providing some common functionality for implementations. + * + * @author Janne Valkealahti + */ +public abstract class AbstractView implements View { + + private final static Logger log = LoggerFactory.getLogger(AbstractView.class); + private final Disposable.Composite disposables = Disposables.composite(); + private int x = 0; + private int y = 0; + private int width = 0; + private int height = 0; + private BiFunction drawFunction; + private boolean hasFocus; + private int layer; + private EventLoop eventLoop; + private Map keyBindings = new HashMap<>(); + private Map mouseBindings = new HashMap<>(); + + public AbstractView() { + init(); + } + + /** + * Register {@link Disposable} to get disposed when view terminates. + * + * @param disposable a disposable to dispose + */ + protected void onDestroy(Disposable disposable) { + disposables.add(disposable); + } + + /** + * Cleans running state of a {@link View} so that it can be left to get garbage + * collected. + */ + public void destroy() { + disposables.dispose(); + } + + /** + * Initialize a view. Mostly reserved for future use and simply calls + * {@link #initInternal()}. + * + * @see #initInternal() + */ + protected final void init() { + initInternal(); + } + + /** + * Internal init method called from {@link #init()}. Override to do something + * usefull. Typically key and mousebindings are registered from this method. + */ + protected void initInternal() { + } + + @Override + public void setRect(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public Rectangle getRect() { + return new Rectangle(x, y, width, height); + } + + @Override + public void setLayer(int index) { + this.layer = index; + } + + protected int getLayer() { + return layer; + } + + @Override + public final void draw(Screen screen) { + drawInternal(screen); + } + + /** + * Component internal drawing method. Implementing classes needs to define this + * method to draw something into a {@link Screen}. + * + * @param screen the screen + */ + protected abstract void drawInternal(Screen screen); + + @Override + public void focus(View view, boolean focus) { + log.debug("Focus view={} focus={}", view, focus); + if (view == this && focus) { + hasFocus = true; + } + if (!focus) { + hasFocus = false; + } + } + + @Override + public boolean hasFocus() { + return hasFocus; + } + + /** + * Handles mouse events by dispatching registered consumers into an event loop. + * Override to change default behaviour. + */ + @Override + public MouseHandler getMouseHandler() { + log.trace("getMouseHandler() {}", this); + MouseHandler handler = args -> { + MouseEvent event = args.event(); + int mouse = event.mouse(); + View view = null; + boolean consumed = false; + MouseBindingValue mouseBindingValue = getMouseBindings().get(mouse); + if (mouseBindingValue != null) { + if (mouseBindingValue.mousePredicate().test(event)) { + view = this; + consumed = dispatchMouseRunCommand(event, mouseBindingValue); + } + } + return MouseHandler.resultOf(args.event(), consumed, view, this); + }; + return handler; + } + + /** + * Handles keys by dispatching registered command runnable into an event loop. + * Override to change default behaviour. + */ + @Override + public KeyHandler getKeyHandler() { + log.trace("getKeyHandler() {}", this); + KeyHandler handler = args -> { + KeyEvent event = args.event(); + boolean consumed = false; + Integer key = event.key(); + if (key != null) { + KeyBindingValue keyBindingValue = getKeyBindings().get(key); + if (keyBindingValue != null) { + consumed = dispatchRunCommand(event, keyBindingValue); + } + + } + return KeyHandler.resultOf(event, consumed, null); + }; + return handler; + } + + /** + * Sets a callback function which is invoked after a {@link View} has been + * drawn. + * + * @param drawFunction the draw function + */ + public void setDrawFunction(BiFunction drawFunction) { + this.drawFunction = drawFunction; + } + + /** + * Gets a draw function. + * + * @return null if function is not set + * @see #setDrawFunction(BiFunction) + */ + public BiFunction getDrawFunction() { + return drawFunction; + } + + /** + * Set an {@link EventLoop}. + * + * @param eventLoop the event loop + */ + public void setEventLoop(@Nullable EventLoop eventLoop) { + this.eventLoop = eventLoop; + } + + /** + * Get an {@link EventLoop}. + * + * @return event loop + */ + protected EventLoop getEventLoop() { + return eventLoop; + } + + protected void registerKeyBinding(Integer keyType, String keyCommand) { + registerKeyBinding(keyType, keyCommand, null, null); + } + + protected void registerKeyBinding(Integer keyType, KeyBindingConsumer keyConsumer) { + registerKeyBinding(keyType, null, keyConsumer, null); + } + + protected void registerKeyBinding(Integer keyType, Runnable keyRunnable) { + registerKeyBinding(keyType, null, null, keyRunnable); + } + + private void registerKeyBinding(Integer keyType, String keyCommand, KeyBindingConsumer keyConsumer, Runnable keyRunnable) { + keyBindings.compute(keyType, (key, old) -> { + return KeyBindingValue.of(old, keyCommand, keyConsumer, keyRunnable); + }); + } + + record KeyBindingValue(String keyCommand, KeyBindingConsumer keyConsumer, Runnable keyRunnable) { + static KeyBindingValue of(KeyBindingValue old, String keyCommand, KeyBindingConsumer keyConsumer, + Runnable keyRunnable) { + if (old == null) { + return new KeyBindingValue(keyCommand, keyConsumer, keyRunnable); + } + return new KeyBindingValue(keyCommand != null ? keyCommand : old.keyCommand(), + keyConsumer != null ? keyConsumer : old.keyConsumer(), + keyRunnable != null ? keyRunnable : old.keyRunnable()); + } + } + + /** + * Get key bindings. + * + * @return key bindings + */ + protected Map getKeyBindings() { + return keyBindings; + } + + record MouseBindingValue(String mouseCommand, MouseBindingConsumer mouseConsumer, Runnable mouseRunnable, + Predicate mousePredicate) { + static MouseBindingValue of(MouseBindingValue old, String mouseCommand, MouseBindingConsumer mouseConsumer, + Runnable mouseRunnable, Predicate mousePredicate) { + if (old == null) { + return new MouseBindingValue(mouseCommand, mouseConsumer, mouseRunnable, mousePredicate); + } + return new MouseBindingValue(mouseCommand != null ? mouseCommand : old.mouseCommand(), + mouseConsumer != null ? mouseConsumer : old.mouseConsumer(), + mouseRunnable != null ? mouseRunnable : old.mouseRunnable(), + mousePredicate != null ? mousePredicate : old.mousePredicate()); + } + } + + /** + * Get mouse bindings. + * + * @return mouse bindings + */ + protected Map getMouseBindings() { + return mouseBindings; + } + + protected void registerMouseBinding(Integer keyType, String mouseCommand) { + registerMouseBinding(keyType, mouseCommand, null, null); + } + + protected void registerMouseBinding(Integer keyType, MouseBindingConsumer mouseConsumer) { + registerMouseBinding(keyType, null, mouseConsumer, null); + } + + protected void registerMouseBinding(Integer keyType, Runnable mouseRunnable) { + registerMouseBinding(keyType, null, null, mouseRunnable); + } + + private void registerMouseBinding(Integer mouseType, String mouseCommand, MouseBindingConsumer mouseConsumer, Runnable mouseRunnable) { + Predicate mousePredicate = event -> { + int x = event.x(); + int y = event.y(); + return getRect().contains(x, y); + }; + mouseBindings.compute(mouseType, (key, old) -> { + return MouseBindingValue.of(old, mouseCommand, mouseConsumer, mouseRunnable, mousePredicate); + }); + } + + /** + * Dispatch a {@link Message} into an event loop. + * + * @param message the message to dispatch + */ + protected void dispatch(Message message) { + if (eventLoop != null) { + eventLoop.dispatch(message); + } + else { + log.warn("Can't dispatch message {} as eventloop is not set", message); + } + } + + protected boolean dispatchRunnable(Runnable runnable) { + if (eventLoop == null) { + return false; + } + Message message = ShellMessageBuilder + .withPayload(runnable) + .setEventType(EventLoop.Type.TASK) + .build(); + dispatch(message); + return true; + } + + protected boolean dispatchRunCommand(KeyEvent event, KeyBindingValue command) { + if (eventLoop == null) { + return false; + } + Runnable runnable = command.keyRunnable(); + if (runnable != null) { + Message message = ShellMessageBuilder + .withPayload(runnable) + .setEventType(EventLoop.Type.TASK) + .build(); + dispatch(message); + return true; + } + KeyBindingConsumer keyConsumer = command.keyConsumer(); + if (keyConsumer != null) { + Message message = ShellMessageBuilder + .withPayload(new KeyBindingConsumerArgs(keyConsumer, event)) + .setEventType(EventLoop.Type.TASK) + .build(); + dispatch(message); + return true; + } + return false; + } + + protected boolean dispatchMouseRunCommand(MouseEvent event, MouseBindingValue command) { + if (eventLoop == null) { + return false; + } + Runnable runnable = command.mouseRunnable(); + if (runnable != null) { + Message message = ShellMessageBuilder + .withPayload(runnable) + .setEventType(EventLoop.Type.TASK) + .build(); + dispatch(message); + return true; + } + MouseBindingConsumer mouseConsumer = command.mouseConsumer(); + if (mouseConsumer != null) { + Message message = ShellMessageBuilder + .withPayload(new MouseBindingConsumerArgs(mouseConsumer, event)) + .setEventType(EventLoop.Type.TASK) + .build(); + dispatch(message); + return true; + } + return false; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java new file mode 100644 index 000000000..7d24f3e10 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java @@ -0,0 +1,136 @@ +/* + * Copyright 2023 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; + +/** + * {@link AppView} provides an opinionated terminal UI application view + * controlling main viewing area, menubar, statusbar and modal window system. + * + * @author Janne Valkealahti + */ +public class AppView extends BoxView { + + private final static Logger log = LoggerFactory.getLogger(AppView.class); + private View main; + private View modal; + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + if (main != null) { + main.setRect(rect.x(), rect.y(), rect.width(), rect.height()); + main.draw(screen); + } + if (modal != null) { + modal.setLayer(1); + modal.setRect(rect.x() + 5, rect.y() + 5, rect.width() - 10, rect.height() - 10); + modal.draw(screen); + } + super.drawInternal(screen); + } + + @Override + public MouseHandler getMouseHandler() { + log.trace("getMouseHandler()"); + if (main != null) { + MouseHandler handler = main.getMouseHandler(); + if (handler != null) { + return handler; + } + } + return super.getMouseHandler(); + } + + @Override + public KeyHandler getKeyHandler() { + KeyHandler handler1 = args -> { + KeyEvent event = args.event(); + boolean consumed = false; + if (event.isKey(Key.CursorLeft)) { + dispatch(ShellMessageBuilder.ofView(this, AppViewEvent.of(this, AppViewEventArgs.Direction.PREVIOUS))); + consumed = true; + } + else if (event.isKey(Key.CursorRight)) { + dispatch(ShellMessageBuilder.ofView(this, AppViewEvent.of(this, AppViewEventArgs.Direction.NEXT))); + consumed = true; + } + return KeyHandler.resultOf(event, consumed, null); + }; + + KeyHandler handler2 = main != null ? main.getKeyHandler() : super.getKeyHandler(); + return handler2.thenIfNotConsumed(handler1); + } + + @Override + public boolean hasFocus() { + if (main != null) { + return main.hasFocus(); + } + return super.hasFocus(); + } + + public void setMain(View main) { + this.main = main; + } + + public void setModal(View modal) { + this.modal = modal; + } + + /** + * {@link ViewEventArgs} for {@link AppViewEvent}. + * + * @param direction the direction enumeration + */ + public record AppViewEventArgs(Direction direction) implements ViewEventArgs { + + /** + * Direction where next selection should go. + */ + public enum Direction { + PREVIOUS, + NEXT + } + + public static AppViewEventArgs of(Direction direction) { + return new AppViewEventArgs(direction); + } + } + + /** + * {@link ViewEvent} indicating direction for a next selection. + * + * @param view the view sending an event + * @param args the event args + */ + public record AppViewEvent(View view, AppViewEventArgs args) implements ViewEvent { + + public static AppViewEvent of(View view, AppViewEventArgs.Direction direction) { + return new AppViewEvent(view, AppViewEventArgs.of(direction)); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/BoxView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/BoxView.java new file mode 100644 index 000000000..f17c2f09b --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/BoxView.java @@ -0,0 +1,239 @@ +/* + * Copyright 2023 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.lang.Nullable; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.geom.VerticalAlign; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.util.StringUtils; + +/** + * {@code BoxView} is a {@link View} with an empty background and optional + * border and title. All "boxed" views can use this as their base + * implementation by either subclassing or wrapping. + * + * @author Janne Valkealahti + */ +public class BoxView extends AbstractView { + + private final static Logger log = LoggerFactory.getLogger(BoxView.class); + private String title = null; + private boolean showBorder = false; + private int innerX = -1; + private int innerY; + private int innerWidth; + private int innerHeight; + private int paddingTop; + private int paddingBottom; + private int paddingLeft; + private int paddingRight; + private Integer backgroundColor = -1; + private int titleColor = -1; + private int titleStyle = -1; + private HorizontalAlign titleAlign; + + @Override + public void setRect(int x, int y, int width, int height) { + this.innerX = -1; + super.setRect(x, y, width, height); + } + + // @Override + // public MouseHandler getMouseHandler() { + // log.trace("getMouseHandler() {}", this); + // return args -> { + // // box view only handles "mouse click on button 1" + // View view = null; + // MouseEvent event = args.event(); + // if (event.getModifiers().isEmpty() && event.getType() == MouseEvent.Type.Released + // && event.getButton() == MouseEvent.Button.Button1) { + // int x = event.getX(); + // int y = event.getY(); + // if (getRect().contains(x, y)) { + // view = this; + // } + // } + // return MouseHandler.resultOf(args.event(), view != null, view, this); + // }; + // } + + /** + * Sets a paddings for this view. + * + * @param paddingTop the top padding + * @param paddingBottom the bottom padding + * @param paddingLeft the left padding + * @param paddingRight the right padding + * @return a BoxView for chaining + */ + public BoxView setBorderPadding(int paddingTop, int paddingBottom, int paddingLeft, int paddingRight) { + this.paddingTop = paddingTop; + this.paddingBottom = paddingBottom; + this.paddingLeft = paddingLeft; + this.paddingRight = paddingRight; + return this; + } + + /** + * Defines if border is shown. + * + * @param showBorder the flag showing border + */ + public void setShowBorder(boolean showBorder) { + this.showBorder = showBorder; + } + + /** + * Returns if border is shown. + * + * @return true if border is shown + */ + public boolean isShowBorder() { + return showBorder; + } + + /** + * Sets a title. {@code title} is shown within a top-level border boundary and + * will not be visible if border itself is not visible. + * + * @param title the border title + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Sets a background color. If color is set to {@code null} it indicates that + * background should be set to be {@code empty} causing possible layer to be + * non-transparent. + * + * @param backgroundColor the background color + */ + public void setBackgroundColor(@Nullable Integer backgroundColor) { + this.backgroundColor = backgroundColor; + } + + /** + * Sets a title color. + * + * @param titleColor the title color + */ + public void setTitleColor(int titleColor) { + this.titleColor = titleColor; + } + + /** + * Sets a title style. + * + * @param titleStyle the title style + */ + public void setTitleStyle(int titleStyle) { + this.titleStyle = titleStyle; + } + + /** + * Sets a title align. + * + * @param titleAlign the title align + */ + public void setTitleAlign(HorizontalAlign titleAlign) { + this.titleAlign = titleAlign; + } + + /** + * Possibly draws a box around this view and title in a box top boundary. Also + * calls a {@code draw function} if defined. + * + * @param screen the screen + */ + protected void drawInternal(Screen screen) { + log.trace("drawInternal() {}", this); + Rectangle rect = getRect(); + if (rect.width() <= 0 || rect.height() <= 0) { + return; + } + if (backgroundColor == null) { + screen.writerBuilder().layer(getLayer()).build().background(rect, -1); + } + else if (backgroundColor > -1) { + screen.writerBuilder().layer(getLayer()).build().background(rect, backgroundColor); + } + if (showBorder && rect.width() >= 2 && rect.height() >= 2) { + screen.writerBuilder().layer(getLayer()).build().border(rect.x(), rect.y(), rect.width(), rect.height()); + if (StringUtils.hasText(title)) { + Rectangle r = new Rectangle(rect.x() + 1, rect.y(), rect.width() - 2, 1); + if (titleColor > -1) { + screen.writerBuilder().layer(getLayer()).color(titleColor).style(titleStyle).build().text(title, r, titleAlign, VerticalAlign.TOP); + } + else { + screen.writerBuilder().layer(getLayer()).build().text(title, r, titleAlign, VerticalAlign.TOP); + } + } + } + if (getDrawFunction() != null) { + Rectangle r = getDrawFunction().apply(screen, rect); + innerX = r.x(); + innerY = r.y(); + innerWidth = r.width(); + innerHeight = r.height(); + } + else { + Rectangle r = getInnerRect(); + innerX = r.x(); + innerY = r.y(); + innerWidth = r.width(); + innerHeight = r.height(); + } + } + + /** + * Gets an inner rectangle of this view. + * + * @return an inner rectangle of this view + */ + protected Rectangle getInnerRect() { + if (innerX >= 0) { + return new Rectangle(innerX, innerY, innerWidth, innerHeight); + } + Rectangle rect = getRect(); + int x = rect.x(); + int y = rect.y(); + int width = rect.width(); + int height = rect.height(); + if (isShowBorder()) { + x++; + y++; + width -= 2; + height -= 2; + } + x = x + paddingLeft; + y = y + paddingTop; + width = width - paddingLeft - paddingRight; + height = height - paddingTop - paddingBottom; + if (width < 0) { + width = 0; + } + if (height < 0) { + height = 0; + } + return new Rectangle(x, y, width, height); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Cell.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Cell.java new file mode 100644 index 000000000..4084bcd11 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Cell.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.screen.Screen; + +/** + * Base interface for all cells. Typically a {@link Cell} is a building block in + * a {@link View} not needing to be aware of how it is drawn into a {@link Screen} + * but needs to aware of its "item", bounds via {@link Control} and other + * properties like {@code background}. + * + * @author Janne Valkealahti + */ +public interface Cell extends Control { + + /** + * Get item bound to a cell. + * + * @return item bound to a cell + */ + T getItem(); + + /** + * Sets an item to bound into a cell. + * + * @param item item to bound into a cell + */ + void setItem(T item); + + /** + * Sets a style. + * + * @param style the style + */ + void setStyle(int style); + + /** + * Sets a foreground color. + * + * @param foregroundColor the background color + */ + void setForegroundColor(int foregroundColor); + + /** + * Sets a background color. + * + * @param backgroundColor the background color + */ + void setBackgroundColor(int backgroundColor); + + /** + * Return if cell is selected. + * + * @return true if cell is selected + */ + boolean isSelected(); + + /** + * Update selected status. + * + * @param selected true if cell is selected + */ + void updateSelected(boolean selected); + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Control.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Control.java new file mode 100644 index 000000000..5080bf086 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/Control.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; + +/** + * Base interface for all controls. {@link Control} is able to define a + * {@link Rectangle} it is bound to and draw into a {@link Screen}. + * + * @author Janne Valkealahti + * @see View + * @see Cell + */ +public interface Control { + + /** + * Draw {@link Control} into a {@link Screen}. + * + * @param screen the screen + */ + void draw(Screen screen); + + /** + * Gets rectanle of a bounded box for this {@link View}. + * + * @return the rectanle of a bounded box + */ + Rectangle getRect(); + + /** + * Sets bounds where this {@link Control} should operate. + * + * @param x a x coord of a bounded box + * @param y an y coord of a bounded box + * @param width a width of a bounded box + * @param height a height of a bounded box + */ + void setRect(int x, int y, int width, int height); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java new file mode 100644 index 000000000..09d528d2b --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java @@ -0,0 +1,541 @@ +/* + * Copyright 2023 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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; + +/** + * {@code GridView} is a layout container with no initial {@link View views}. + * + * Loosely based on ideas from other grid layouts having features like rows and + * columns, column and row spanning, dynamic layouts based on container size + * using "CSS media queries" type of structure. + * + * @author Janne Valkealahti + */ +public class GridView extends BoxView { + + private final static Logger log = LoggerFactory.getLogger(GridView.class); + private List gridItems = new ArrayList<>(); + private int[] columnSize; + private int[] rowSize; + private int minWidth; + private int minHeight; + private int gapRows; + private int gapColumns; + private int rowOffset; + private int columnOffset; + private boolean showBorders; + + /** + * Defines how the columns of the grid are distributed. Each value + * defines the size of one column, starting with the leftmost column. Values + * greater 0 represent absolute column widths (gaps not included). Values less + * or equal 0 represent proportional column widths or fractions of the remaining + * free space, where 0 is treated the same as -1. That is, a column with a value + * of -3 will have three times the width of a column with a value of -1 (or 0). + * The minimum width set with SetMinSize() is always observed. + * + * Views may extend beyond the columns defined explicitly with this + * function. A value of 0 is assumed for any undefined column. In fact, if you + * never call this function, all columns occupied by Views will have the + * same width. On the other hand, unoccupied columns defined with this function + * will always take their place. + * + * Assuming a total width of the grid of 100 cells and a minimum width of 0, the + * following call will result in columns with widths of 30, 10, 15, 15, and 30 + * cells: + * + * grid.SetColumns(30, 10, -1, -1, -2) + * + * If a primitive were then placed in the 6th and 7th column, the resulting + * widths would be: 30, 10, 10, 10, 20, 10, and 10 cells. + * + * If you then called SetMinSize() as follows: + * + * grid.SetMinSize(15, 20) + * + * The resulting widths would be: 30, 15, 15, 15, 20, 15, and 15 cells, a total + * of 125 cells, 25 cells wider than the available grid width. + * + * @param columns + * @return + */ + public GridView setColumnSize(int... columns) { + this.columnSize = columns; + return this; + } + + /** + * + * @param rows + * @return + * @see #setColumnSize(int...) + */ + public GridView setRowSize(int... rows) { + this.rowSize = rows; + return this; + } + + public GridView addItem(View view, int row, int column, int rowSpan, int colSpan, int minGridHeight, + int minGridWidth) { + GridItem gridItem = new GridItem(view, row, column, colSpan, rowSpan, minGridHeight, + minGridWidth, false); + gridItems.add(gridItem); + return this; + } + + /** + * Defines if borders is shown. + * + * @param showBorders the flag showing borders + */ + public void setShowBorders(boolean showBorders) { + this.showBorders = showBorders; + } + + /** + * Returns if borders is shown. + * + * @return true if borders is shown + */ + public boolean isShowBorders() { + return showBorders; + } + + @Override + public MouseHandler getMouseHandler() { + log.trace("getMouseHandler()"); + return args -> { + View focus = null; + for (GridItem i : gridItems) { + MouseHandlerResult r = i.view.getMouseHandler().handle(args); + if (r.focus() != null) { + focus = r.focus(); + break; + } + } + return MouseHandler.resultOf(args.event(), true, focus, null); + }; + } + + @Override + public KeyHandler getKeyHandler() { + log.trace("getKeyHandler()"); + for (GridItem i : gridItems) { + if (i.view.hasFocus()) { + return i.view.getKeyHandler(); + } + } + return super.getKeyHandler(); + } + + @Override + public boolean hasFocus() { + for (GridItem i : gridItems) { + if (i.view.hasFocus()) { + return true; + } + } + return super.hasFocus(); + } + + @Override + protected void drawInternal(Screen screen) { + super.drawInternal(screen); + Rectangle rect = getInnerRect(); + int x = rect.x(); + int y = rect.y(); + int width = rect.width(); + int height = rect.height(); + + Map items = new HashMap<>(); + for (GridItem item : gridItems) { + item.visible = false; + if (item.width <= 0 || item.height <= 0 || width < item.minGridWidth || height < item.minGridHeight) { + continue; + } + GridItem previousItem = items.get(item.view); + if (previousItem != null && item.minGridWidth < previousItem.minGridWidth + && item.minGridHeight < previousItem.minGridHeight) { + continue; + } + items.put(item.view, item); + } + + // How many rows and columns do we have? + int rows = rowSize.length; + int columns = columnSize.length; + for (GridItem item : items.values()) { + int rowEnd = item.row + item.height; + if (rowEnd > rows) { + rows = rowEnd; + } + int columnEnd = item.column + item.width; + if (columnEnd > columns) { + columns = columnEnd; + } + } + if (rows == 0 || columns == 0) { + return; + } + + // Where are they located? + int[] rowPos = new int[rows]; + int[] rowHeight = new int[rows]; + int[] columnPos = new int[columns]; + int[] columnWidth = new int[columns]; + + // How much space do we distribute? + int remainingWidth = width; + int remainingHeight = height; + int proportionalWidth = 0; + int proportionalHeight = 0; + + + for (int index = 0; index < rowSize.length; index++) { + int row = rowSize[index]; + if (row > 0) { + if (row < this.minHeight) { + row = this.minHeight; + } + remainingHeight -= row; + rowHeight[index] = row; + } + else if (row == 0) { + proportionalHeight++; + } + else { + proportionalHeight += -row; + } + } + + for (int index = 0; index < columnSize.length; index++) { + int column = columnSize[index]; + if (column > 0) { + if (column < this.minWidth) { + column = this.minWidth; + } + remainingWidth -= column; + columnWidth[index] = column; + } + else if (column == 0) { + proportionalWidth++; + } + else { + proportionalWidth += -column; + } + } + + if (isShowBorders()) { + remainingHeight -= rows + 1; + remainingWidth -= columns + 1; + } + else { + remainingHeight -= (rows - 1) * this.gapRows; + remainingWidth -= (columns - 1) * this.gapColumns; + } + if (rows > this.rowSize.length) { + proportionalHeight += rows - this.rowSize.length; + } + if (columns > this.columnSize.length) { + proportionalWidth += columns - this.columnSize.length; + } + + // Distribute proportional rows/columns. + for (int index = 0; index < rows; index++) { + int row = 0; + if (index < this.rowSize.length) { + row = this.rowSize[index]; + } + if (row > 0) { + continue; + } + else if (row == 0) { + row = 1; + } + else { + row = -row; + } + int rowAbs = row * remainingHeight / proportionalHeight; + remainingHeight -= rowAbs; + proportionalHeight -= row; + if (rowAbs < this.minHeight) { + rowAbs = this.minHeight; + } + rowHeight[index] = rowAbs; + } + + for (int index = 0; index < columns; index++) { + int column = 0; + if (index < this.columnSize.length) { + column = this.columnSize[index]; + } + if (column > 0) { + continue; + } + else if (column == 0) { + column = 1; + } + else { + column = -column; + } + int columnAbs = column * remainingWidth / proportionalWidth; + remainingWidth -= columnAbs; + proportionalWidth -= column; + if (columnAbs < this.minWidth) { + columnAbs = this.minWidth; + } + columnWidth[index] = columnAbs; + } + + // Calculate row/column positions. + int columnX = 0, rowY = 0; + if (isShowBorders()) { + columnX++; + rowY++; + } + for (int index = 0; index < rowHeight.length; index++) { + int row = rowHeight[index]; + rowPos[index] = rowY; + int gap = this.gapRows; + if (isShowBorders()) { + gap = 1; + } + rowY += row + gap; + } + for (int index = 0; index < columnWidth.length; index++) { + int column = columnWidth[index]; + columnPos[index] = columnX; + int gap = this.gapColumns; + if (isShowBorders()) { + gap = 1; + } + columnX += column + gap; + } + + // Calculate primitive positions. + GridItem focus = null; + for (Entry entry : items.entrySet()) { + View primitive = entry.getKey(); + GridItem item = entry.getValue(); + int px = columnPos[item.column]; + int py = rowPos[item.row]; + int pw = 0, ph = 0; + for (int index = 0; index < item.height; index++) { + ph += rowHeight[item.row + index]; + } + for (int index = 0; index < item.width; index++) { + pw += columnWidth[item.column + index]; + } + if (isShowBorders()) { + pw += item.width - 1; + ph += item.height - 1; + } + else { + pw += (item.width - 1) * this.gapColumns; + ph += (item.height - 1) * this.gapRows; + } + item.x = px; + item.y = py; + item.w = pw; + item.h = ph; + item.visible = true; + if (primitive.hasFocus()) { + focus = item; + } + } + + // Calculate screen offsets. + int offsetX = 0; + int offsetY = 0; + int add = 1; + if (!isShowBorders()) { + add = this.gapRows; + } + for (int index = 0; index < rowHeight.length; index++) { + int height2 = rowHeight[index]; + if (index >= this.rowOffset) { + break; + } + offsetY += height2 + add; + } + if (!isShowBorders()) { + add = this.gapColumns; + } + for (int index = 0; index < columnWidth.length; index++) { + int width2 = columnWidth[index]; + if (index >= this.columnOffset) { + break; + } + offsetX += width2 + add; + } + + // Line up the last row/column with the end of the available area. + int border = 0; + if (isShowBorders()) { + border = 1; + } + int last = rowPos.length - 1; + if (rowPos[last] + rowHeight[last] + border - offsetY < height) { + offsetY = rowPos[last] - height + rowHeight[last] + border; + } + last = columnPos.length - 1; + if (columnPos[last] + columnWidth[last] + border - offsetX < width) { + offsetX = columnPos[last] - width + columnWidth[last] + border; + } + + // The focused item must be within the visible area. + if (focus != null) { + if (focus.y + focus.h - offsetY >= height) { + offsetY = focus.y - height + focus.h; + } + if (focus.y - offsetY < 0) { + offsetY = focus.y; + } + if (focus.x + focus.w - offsetX >= width) { + offsetX = focus.x - width + focus.w; + } + if (focus.x - offsetX < 0) { + offsetX = focus.x; + } + } + + // Adjust row/column offsets based on this value. + int from = 0; + int to = 0; + for (int index = 0; index < rowPos.length; index ++) { + int pos = rowPos[index]; + if (pos - offsetY < 0) { + from = index + 1; + } + if (pos - offsetY < height) { + to = index; + } + } + if (this.rowOffset < from) { + this.rowOffset = from; + } + if (this.rowOffset > to) { + this.rowOffset = to; + } + + from = 0; + to = 0; + for (int index = 0; index < columnPos.length; index ++) { + int pos = columnPos[index]; + if (pos - offsetX < 0) { + from = index + 1; + } + if (pos - offsetX < width) { + to = index; + } + } + if (this.columnOffset < from) { + this.columnOffset = from; + } + if (this.columnOffset > to) { + this.columnOffset = to; + } + + // Draw primitives and borders. + for (Entry entry : items.entrySet()) { + View view = entry.getKey(); + GridItem item = entry.getValue(); + + // Final primitive position. + if (!item.visible) { + continue; + } + + item.x -= offsetX; + item.y -= offsetY; + if (item.x >= width || item.x + item.w <= 0 || item.y >= height || item.y + item.h <= 0) { + item.visible = false; + continue; + } + + if (item.x + item.w > width) { + item.w = width - item.x; + } + if (item.y + item.h > height) { + item.h = height - item.y; + } + if (item.x < 0) { + item.w += item.x; + item.x = 0; + } + if (item.y < 0) { + item.h += item.y; + item.y = 0; + } + if (item.w <= 0 || item.h <= 0) { + item.visible = false; + continue; + } + + item.x += x; + item.y += y; + view.setRect(item.x, item.y, item.w, item.h); + + view.draw(screen); + + // Draw border around primitive. + if (isShowBorders()) { + screen.writerBuilder().build().border(item.x - 1, item.y - 1, item.w + 2, item.h + 2); + } + } + } + + private static class GridItem { + View view; + int row; + int column; + int width; + int height; + int minGridHeight; + int minGridWidth; + boolean visible; + // The last position of the item relative to the top-left + // corner of the grid. Undefined if visible is false. + int x, y, w, h; + + GridItem(View view, int row, int column, int width, int height, int minGridHeight, int minGridWidth, + boolean visible) { + this.view = view; + this.row = row; + this.column = column; + this.width = width; + this.height = height; + this.minGridHeight = minGridHeight; + this.minGridWidth = minGridWidth; + this.visible = visible; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/InputView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/InputView.java new file mode 100644 index 000000000..1d349bea8 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/InputView.java @@ -0,0 +1,103 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.geom.Position; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; + +/** + * {@code InputView} is used as a text input. + * + * @author Janne Valkealahti + */ +public class InputView extends BoxView { + + private final StringBuilder text = new StringBuilder(); + private int cursorPosition = 0; + + @Override + protected void initInternal() { + registerKeyBinding(Key.CursorLeft, event -> left()); + registerKeyBinding(Key.CursorRight, event -> right()); + registerKeyBinding(Key.Delete, () -> delete()); + registerKeyBinding(Key.Backspace, () -> backspace()); + } + + @Override + public KeyHandler getKeyHandler() { + KeyHandler handler = args -> { + KeyEvent event = args.event(); + boolean consumed = false; + if (event.isKey()) { + consumed = true; + int plainKey = event.getPlainKey(); + add(new String(new char[]{(char)plainKey})); + } + return KeyHandler.resultOf(event, consumed, null); + }; + return handler.thenIfNotConsumed(super.getKeyHandler()); + } + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + String s = text.toString(); + screen.writerBuilder().build().text(s, rect.x(), rect.y()); + screen.setShowCursor(hasFocus()); + screen.setCursorPosition(new Position(rect.x() + cursorPosition, rect.y())); + super.drawInternal(screen); + } + + public String getInputText() { + return text.toString(); + } + + // private void enter(KeyEvent event) { + // // getShellMessageListener().onMessage(ShellMessageBuilder.ofViewFocus("enter", this)); + // } + + // private void leave(KeyEvent event) { + // // getShellMessageListener().onMessage(ShellMessageBuilder.ofViewFocus("leave", this)); + // } + + private void add(String data) { + text.append(data); + right(); + } + + private void backspace() { + if (cursorPosition > 0) { + text.deleteCharAt(cursorPosition - 1); + } + left(); + } + + private void delete() { + text.deleteCharAt(cursorPosition); + } + + private void left() { + cursorPosition--; + } + + private void right() { + cursorPosition++; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ListView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ListView.java new file mode 100644 index 000000000..155760526 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ListView.java @@ -0,0 +1,192 @@ +/* + * Copyright 2023 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.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.control.cell.ListCell; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.ScreenItem; + +/** + * {@link ListView} shows {@code list items} vertically. + * + * @author Janne Valkealahti + */ +public class ListView extends BoxView { + + private final static Logger log = LoggerFactory.getLogger(ListView.class); + + private final List items = new ArrayList<>(); + private int selected = -1; + + private final List> cells = new ArrayList<>(); + private Function, ListCell> factory = listView -> new ListCell<>(); + + /** + * Construct list view with no initial items. + */ + public ListView() { + } + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + int y = rect.y(); + + int i = 0; + for (ListCell c : cells) { + c.setRect(rect.x(), y++, rect.width(), 1); + if (i == selected) { + c.updateSelected(true); + c.setStyle(ScreenItem.STYLE_BOLD); + } + else { + c.updateSelected(false); + c.setBackgroundColor(-1); + c.setStyle(-1); + } + c.updateSelected(i == selected); + c.draw(screen); + i++; + } + super.drawInternal(screen); + } + + /** + * Sets a cell factory. + * + * @param factory the cell factory + */ + public void setCellFactory(Function, ListCell> factory) { + this.factory = factory; + } + + public void setItems(List items) { + this.items.clear(); + this.items.addAll(items); + this.cells.clear(); + for (T i : items) { + ListCell c = factory.apply(this); + cells.add(c); + c.updateItem(i); + } + } + + @Override + protected void initInternal() { + registerKeyBinding(Key.CursorUp, () -> up()); + registerKeyBinding(Key.CursorDown, () -> down()); + registerKeyBinding(Key.Enter, () -> enter()); + + registerMouseBinding(MouseEvent.Type.Wheel | MouseEvent.Button.WheelUp, () -> up()); + registerMouseBinding(MouseEvent.Type.Wheel | MouseEvent.Button.WheelDown, () -> down()); + registerMouseBinding(MouseEvent.Type.Released | MouseEvent.Button.Button1, () -> {}); + } + + private void up() { + updateIndex(-1); + dispatch(ShellMessageBuilder.ofView(this, ListViewSelectedItemChangedEvent.of(this, selectedItem()))); + } + + private void down() { + updateIndex(1); + dispatch(ShellMessageBuilder.ofView(this, ListViewSelectedItemChangedEvent.of(this, selectedItem()))); + } + + private void enter() { + log.info("XXX enter"); + dispatch(ShellMessageBuilder.ofView(this, ListViewOpenSelectedItemEvent.of(this, selectedItem()))); + } + + public void setSelected(int selected) { + if (this.selected != selected) { + this.selected = selected; + dispatch(ShellMessageBuilder.ofView(this, ListViewSelectedItemChangedEvent.of(this, selectedItem()))); + } + } + + private T selectedItem() { + T selectedItem = null; + if (selected >= 0 && selected < items.size()) { + selectedItem = items.get(selected); + } + return selectedItem; + } + + private void updateIndex(int step) { + int size = items.size(); + if (step > 0) { + if (selected + step < size) { + selected += step; + } + } + else if (step < 0) { + if (selected - step > 0) { + selected += step; + } + } + } + + /** + * {@link ViewEventArgs} for {@link ListViewOpenSelectedItemEvent} and + * {@link ListViewSelectedItemChangedEvent}. + * + * @param item the list view item + */ + public record ListViewItemEventArgs(T item) implements ViewEventArgs { + + public static ListViewItemEventArgs of(T item) { + return new ListViewItemEventArgs(item); + } + } + + /** + * {@link ViewEvent} indicating that selected item has been requested to open. + * + * @param view the view sending an event + * @param args the event args + */ + public record ListViewOpenSelectedItemEvent(View view, ListViewItemEventArgs args) implements ViewEvent { + + public static ListViewOpenSelectedItemEvent of(View view, T item) { + return new ListViewOpenSelectedItemEvent(view, ListViewItemEventArgs.of(item)); + } + } + + /** + * {@link ViewEvent} indicating that selected item has changed. + * + * @param view the view sending an event + * @param args the event args + */ + public record ListViewSelectedItemChangedEvent(View view, ListViewItemEventArgs args) implements ViewEvent { + + public static ListViewSelectedItemChangedEvent of(View view, T item) { + return new ListViewSelectedItemChangedEvent(view, ListViewItemEventArgs.of(item)); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuBarView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuBarView.java new file mode 100644 index 000000000..effc276d2 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuBarView.java @@ -0,0 +1,274 @@ +/* + * Copyright 2023 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.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.ListIterator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.control.MenuView.MenuItem; +import org.springframework.shell.component.view.control.MenuView.MenuViewOpenSelectedItemEvent; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.geom.Dimension; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.shell.component.view.screen.ScreenItem; + +/** + * {@link MenuBarView} shows {@link MenuBarItem items} horizontally and is + * typically used in layouts which builds complete terminal UI's. + * + * Internally {@link MenuView} is used to show the menus. + * + * @author Janne Valkealahti + */ +public class MenuBarView extends BoxView { + + private final Logger log = LoggerFactory.getLogger(MenuBarView.class); + private final List items = new ArrayList<>(); + private MenuView currentMenuView; + private int activeItemIndex = -1; + + /** + * Construct menubar view with menubar items. + * + * @param items the menubar items + */ + public MenuBarView(MenuBarItem[] items) { + setItems(Arrays.asList(items)); + } + + /** + * Construct menubar view with menubar items. + * + * @param items the menubar items + */ + public static MenuBarView of(MenuBarItem... items) { + return new MenuBarView(items); + } + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + log.debug("Drawing menu bar to {}", rect); + Writer writer1 = screen.writerBuilder().build(); + Writer writer2 = screen.writerBuilder().style(ScreenItem.STYLE_BOLD).build(); + int x = rect.x(); + ListIterator iter = items.listIterator(); + while (iter.hasNext()) { + MenuBarItem item = iter.next(); + int index = iter.previousIndex(); + Writer writer = activeItemIndex == index ? writer2 : writer1; + String text = String.format(" %s%s", item.getTitle(), iter.hasNext() ? " " : ""); + writer.text(text, x, rect.y()); + x += text.length(); + } + if (currentMenuView != null) { + currentMenuView.draw(screen); + } + super.drawInternal(screen); + } + + @Override + protected void initInternal() { + registerKeyBinding(Key.CursorLeft, () -> left()); + registerKeyBinding(Key.CursorRight, () -> right()); + + registerMouseBinding(MouseEvent.Type.Released | MouseEvent.Button.Button1, event -> select(event)); + } + + @Override + public KeyHandler getKeyHandler() { + // check if possible menuview handles an event + KeyHandler handler = currentMenuView != null ? currentMenuView.getKeyHandler() : KeyHandler.neverConsume(); + return handler.thenIfNotConsumed(super.getKeyHandler()); + } + + @Override + public MouseHandler getMouseHandler() { + // check if possible menuview handles an event + MouseHandler handler = currentMenuView != null ? currentMenuView.getMouseHandler() + : MouseHandler.neverConsume(); + return handler.thenIfNotConsumed(super.getMouseHandler()); + } + + /** + * Gets a menubar items. + * + * @return menubar items + */ + public List getItems() { + return items; + } + + /** + * Sets a selected index. If given index is not within bounds of size of items, + * selection is set to {@code -1} effectively un-selecting active item. + * + * @param index the new index + */ + public void setSelected(int index) { + if (index >= items.size() || index < 0) { + activeItemIndex = -1; + } + else { + activeItemIndex = index; + } + } + + private void left() { + if (activeItemIndex > 0) { + setSelected(activeItemIndex - 1); + checkMenuView(); + } + } + + private void right() { + if (activeItemIndex + 1 < items.size()) { + setSelected(activeItemIndex + 1); + checkMenuView(); + } + } + + private int indexAtPosition(int x, int y) { + Rectangle rect = getRect(); + if (!rect.contains(x, y)) { + return -1; + } + int i = 0; + int p = 1; + for (MenuBarItem item : items) { + p += item.getTitle().length() + 1; + if (x < p) { + return i; + } + i++; + } + return -1; + } + + private int positionAtIndex(int index) { + int i = 0; + int x = 1; + for (MenuBarItem item : items) { + if (i == index) { + return x; + } + x += item.getTitle().length() + 1; + i++; + } + return x; + } + + private void select(MouseEvent event) { + int x = event.x(); + int y = event.y(); + int i = indexAtPosition(x, y); + if (i > -1) { + if (i == activeItemIndex) { + setSelected(-1); + } + else { + setSelected(i); + } + } + checkMenuView(); + } + + private void checkMenuView() { + if (activeItemIndex < 0) { + closeCurrentMenuView(); + } + else { + MenuBarItem item = items.get(activeItemIndex); + currentMenuView = buildMenuView(item); + } + } + + private void closeCurrentMenuView() { + if (currentMenuView != null) { + currentMenuView.destroy(); + } + currentMenuView = null; + } + + private MenuView buildMenuView(MenuBarItem item) { + MenuView menuView = new MenuView(item.getItems()); + menuView.setEventLoop(getEventLoop()); + menuView.setShowBorder(true); + menuView.setBackgroundColor(null); + menuView.setLayer(1); + Rectangle rect = getInnerRect(); + int x = positionAtIndex(activeItemIndex); + Dimension dim = menuView.getPreferredDimension(); + menuView.setRect(rect.x() + x, rect.y() + 1, dim.width(), dim.height()); + menuView.onDestroy(getEventLoop().viewEvents(MenuViewOpenSelectedItemEvent.class, menuView) + .subscribe(event -> { + closeCurrentMenuView(); + })); + + return menuView; + } + + /** + * Sets items. + * + * @param items status items + */ + public void setItems(List items) { + this.items.clear(); + this.items.addAll(items); + } + + /** + * {@link MenuBarItem} represents an item in a {@link MenuBarView}. + */ + public static class MenuBarItem { + + private String title; + private List items; + + public MenuBarItem(String title) { + this(title, null); + } + + public MenuBarItem(String title, MenuItem[] items) { + this.title = title; + this.items = Arrays.asList(items); + } + + public static MenuBarItem of(String title, MenuItem... items) { + return new MenuBarItem(title, items); + } + + public String getTitle() { + return title; + } + + public List getItems() { + return items; + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuView.java new file mode 100644 index 000000000..25e6b5c8e --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/MenuView.java @@ -0,0 +1,474 @@ +/* + * Copyright 2023 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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.lang.Nullable; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.geom.Dimension; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.shell.component.view.screen.ScreenItem; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link MenuView} shows {@link MenuView} items vertically and is + * typically used in layouts which builds complete terminal UI's. + * + * @author Janne Valkealahti + */ +public class MenuView extends BoxView { + + private final Logger log = LoggerFactory.getLogger(MenuView.class); + private final List items = new ArrayList<>(); + private int activeItemIndex = -1; + + /** + * Construct menu view with no initial menu items. + */ + public MenuView() { + this(new MenuItem[0]); + } + + /** + * Construct menu view with menu items. + * + * @param items the menu items + */ + public MenuView(MenuItem[] items) { + this(items != null ? Arrays.asList(items) : Collections.emptyList()); + } + + /** + * Construct menu view with menu items. + * + * @param items the menu items + */ + public MenuView(@Nullable List items) { + setItems(items); + } + + /** + * Sets a new menu items. Will always clear existing items and if {@code null} + * is passed this effectively keeps items empty. + * + * @param items the menu items + */ + public void setItems(@Nullable List items) { + this.items.clear(); + activeItemIndex = -1; + if (items != null) { + this.items.addAll(items); + if (!items.isEmpty()) { + activeItemIndex = 0; + } + } + } + + /** + * Gets a menu items. + * + * @return the menu items + */ + public List getItems() { + return items; + } + + /** + * Gets a preferred dimension menu needs to show it's content. + * + * @return preferred dimension + */ + public Dimension getPreferredDimension() { + int width = 0; + int height = items.size(); + if (isShowBorder()) { + height += 2; + } + for (MenuItem item : items) { + int l = item.getTitle().length(); + if (item.getCheckStyle() != MenuItemCheckStyle.CHECKED) { + l += 4; + if (isShowBorder()) { + l += 2; + } + } + width = Math.max(width, l); + } + return new Dimension(width, height); + } + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + int y = rect.y(); + Writer writer = screen.writerBuilder().layer(getLayer()).build(); + Writer writer2 = screen.writerBuilder().layer(getLayer()).style(ScreenItem.STYLE_BOLD).build(); + int i = 0; + boolean hasCheck = false; + for (MenuItem item : items) { + if (item.getCheckStyle() != MenuItemCheckStyle.NOCHECK) { + hasCheck = true; + break; + } + } + for (MenuItem item : items) { + String prefix = hasCheck + ? (item.getCheckStyle() != MenuItemCheckStyle.NOCHECK + ? (item.isChecked() ? "[x] " : "[ ] ") + : " ") + : ""; + String text = prefix + item.getTitle(); + if (activeItemIndex == i) { + writer2.text(text, rect.x(), y); + } + else { + writer.text(text, rect.x(), y); + } + i++; + y++; + } + super.drawInternal(screen); + } + + @Override + protected void initInternal() { + registerKeyBinding(Key.CursorUp, () -> move(-1)); + registerKeyBinding(Key.CursorDown, () -> move(1)); + registerKeyBinding(Key.Enter, () -> keySelect()); + + registerMouseBinding(MouseEvent.Type.Released | MouseEvent.Button.Button1, event -> mouseSelect(event)); + registerMouseBinding(MouseEvent.Type.Wheel | MouseEvent.Button.WheelDown, () -> move(1)); + registerMouseBinding(MouseEvent.Type.Wheel | MouseEvent.Button.WheelUp, () -> move(-1)); + } + + private void keySelect() { + MenuItem item = items.get(activeItemIndex); + dispatch(ShellMessageBuilder.ofView(this, MenuViewOpenSelectedItemEvent.of(this, item))); + if (item.getAction() != null) { + dispatchRunnable(item.getAction()); + } + } + + private void move(int count) { + log.trace("move({})", count); + setSelected(activeItemIndex + count); + } + + private void setSelected(int index) { + if (index >= items.size()) { + activeItemIndex = 0; + } + else if(index < 0) { + activeItemIndex = items.size() - 1; + } + else { + if (activeItemIndex != index) { + activeItemIndex = index; + MenuItem item = items.get(index); + dispatch(ShellMessageBuilder.ofView(this, MenuViewSelectedItemChangedEvent.of(this, item))); + } + } + } + + private void mouseSelect(MouseEvent event) { + log.trace("select({})", event); + int x = event.x(); + int y = event.y(); + setSelected(indexAtPosition(x, y)); + keySelect(); + } + + private int indexAtPosition(int x, int y) { + Rectangle rect = getRect(); + if (!rect.contains(x, y)) { + return -1; + } + int pos = y - rect.y() - 1; + if (pos > -1 && pos < items.size()) { + MenuItem i = items.get(pos); + if (i != null) { + return pos; + } + } + return -1; + } + + /** + * Specifies how a {@link MenuItem} shows selection state. + */ + public enum MenuItemCheckStyle { + + /** + * The menu item will be shown normally, with no check indicator. The default. + */ + NOCHECK, + + /** + * The menu item will indicate checked/un-checked state. + */ + CHECKED, + + /** + * The menu item is part of a menu radio group and will indicate selected state. + */ + RADIO + } + + /** + * {@link MenuItem} represents an item in a {@link MenuView}. + * + * @see Menu + */ + public static class MenuItem { + + private final String title; + private final MenuItemCheckStyle checkStyle; + private final List items; + private boolean checked; + private Runnable action; + + /** + * Construct menu item with a title. + * + * @param title the title + */ + public MenuItem(String title) { + this(title, MenuItemCheckStyle.NOCHECK); + } + + /** + * Construct menu item with a title and a check style. + * + * @param title the title + * @param checkStyle the check style + */ + public MenuItem(String title, MenuItemCheckStyle checkStyle) { + this(title, checkStyle, null); + } + + /** + * Construct menu item with a title and a check style. + * + * @param title the title + * @param checkStyle the check style + * @param action the action to run when item is chosen + */ + public MenuItem(String title, MenuItemCheckStyle checkStyle, Runnable action) { + Assert.state(StringUtils.hasText(title), "title must have text"); + Assert.notNull(checkStyle, "check style cannot be null"); + this.title = title; + this.checkStyle = checkStyle; + this.action = action; + this.items = null; + } + + protected MenuItem(String title, MenuItem[] items) { + this(title, Arrays.asList(items)); + } + + protected MenuItem(String title, List items) { + Assert.state(StringUtils.hasText(title), "title must have text"); + Assert.notNull(items, "Sub items cannot be null"); + this.title = title; + this.items = items; + this.checkStyle = null; + } + + /** + * Return a {@link MenuItem} with a given {@code title}. + * + * @param title the title + * @return a menu item + */ + public static MenuItem of(String title) { + return new MenuItem(title); + } + + /** + * Return a {@link MenuItem} with a given {@code title} and a + * {@code check style}. + * + * @param title the title + * @param checkStyle the check style + * @return a menu item + */ + public static MenuItem of(String title, MenuItemCheckStyle checkStyle) { + return new MenuItem(title, checkStyle); + } + + public static MenuItem of(String title, MenuItemCheckStyle checkStyle, Runnable action) { + return new MenuItem(title, checkStyle, action); + } + + public Runnable getAction() { + return action; + } + + public void setAction(Runnable action) { + this.action = action; + } + + /** + * Get a {@code title}. Never null, empty or just having white spaces. + * + * @return a title + */ + public String getTitle() { + return title; + } + + /** + * Gets a check style. This will be {@code null} if constructed via + * {@link Menu} as style doesn't apply items being a sub-menu. + * + * @return a check style + */ + @Nullable + public MenuItemCheckStyle getCheckStyle() { + return checkStyle; + } + + /** + * Sets a checked state. + * + * @param checked checked state + */ + public void setChecked(boolean checked) { + this.checked = checked; + } + + /** + * Gets a checked state. + * + * @return checked state + */ + public boolean isChecked() { + return checked; + } + + /** + * Gets sub menu items. This will be {@code null} if not constructed via + * {@link Menu} as plain {@link MenuItem} can't have other items. + * + * @return a menu items + */ + @Nullable + public List getItems() { + return items; + } + } + + /** + * {@link Menu} represents an item in a {@link MenuView} being a specialisation + * of {@link MenuItem} indicating it having a sub-menu. + * + * @see MenuItem + */ + public static class Menu extends MenuItem { + + /** + * Construct menu with a title. + * + * @param title the title + */ + public Menu(String title) { + super(title); + } + + /** + * Construct menu with a title and a menu items. + * + * @param title the title + * @param items the menu items + */ + public Menu(String title, MenuItem[] items) { + super(title, items); + } + + /** + * Construct menu with a title and a menu items. + * + * @param title the title + * @param items the menu items + */ + public Menu(String title, List items) { + super(title, items); + } + + /** + * Return a {@link Menu} with a given {@code title} and {@link MenuItem}s. + * + * @param title the title + * @param items the menu items + * @return a menu + */ + public static Menu of(String title, MenuItem... items) { + return new Menu(title, items); + } + } + + /** + * {@link ViewEventArgs} for {@link MenuViewOpenSelectedItemEvent} and + * {@link MenuViewSelectedItemChangedEvent}. + * + * @param item the menu view item + */ + public record MenuViewItemEventArgs(MenuItem item) implements ViewEventArgs { + + public static MenuViewItemEventArgs of(MenuItem item) { + return new MenuViewItemEventArgs(item); + } + } + + /** + * {@link ViewEvent} indicating that selected item has been requested to open. + * + * @param view the view sending an event + * @param args the event args + */ + public record MenuViewOpenSelectedItemEvent(View view, MenuViewItemEventArgs args) implements ViewEvent { + + public static MenuViewOpenSelectedItemEvent of(View view, MenuItem item) { + return new MenuViewOpenSelectedItemEvent(view, MenuViewItemEventArgs.of(item)); + } + } + + /** + * {@link ViewEvent} indicating that selected item has changed. + * + * @param view the view sending an event + * @param args the event args + */ + public record MenuViewSelectedItemChangedEvent(View view, MenuViewItemEventArgs args) implements ViewEvent { + + public static MenuViewSelectedItemChangedEvent of(View view, MenuItem item) { + return new MenuViewSelectedItemChangedEvent(view, MenuViewItemEventArgs.of(item)); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/StatusBarView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/StatusBarView.java new file mode 100644 index 000000000..e8854f293 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/StatusBarView.java @@ -0,0 +1,185 @@ +/* + * Copyright 2023 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.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.ListIterator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; + +/** + * {@link StatusBarView} shows {@link StatusItem items} horizontally and is + * typically used in layouts which builds complete terminal UI's. + * + * @author Janne Valkealahti + */ +public class StatusBarView extends BoxView { + + private final Logger log = LoggerFactory.getLogger(StatusBarView.class); + private final List items = new ArrayList<>(); + + public StatusBarView() { + this(new StatusItem[0]); + } + + public StatusBarView(StatusItem[] items) { + this(Arrays.asList(items)); + } + + public StatusBarView(List items) { + setItems(items); + } + + @Override + protected void drawInternal(Screen screen) { + Rectangle rect = getInnerRect(); + log.debug("Drawing status bar to {}", rect); + Writer writer = screen.writerBuilder().build(); + int x = rect.x(); + ListIterator iter = items.listIterator(); + while (iter.hasNext()) { + StatusItem item = iter.next(); + String text = String.format(" %s%s", item.getTitle(), iter.hasNext() ? " |" : ""); + writer.text(text, x, rect.y()); + x += text.length(); + } + super.drawInternal(screen); + } + + @Override + public MouseHandler getMouseHandler() { + log.trace("getMouseHandler()"); + return args -> { + MouseEvent event = args.event(); + boolean consumed = false; + if (!event.hasModifier() && event.has(MouseEvent.Type.Released) && event.has(MouseEvent.Button.Button1)) { + int x = event.x(); + int y = event.y(); + StatusItem item = itemAt(x, y); + if (item != null) { + dispatch(ShellMessageBuilder.ofView(this, StatusBarViewOpenSelectedItemEvent.of(this, item))); + if (item.getAction() != null) { + dispatchRunnable(item.getAction()); + } + } + consumed = true; + } + // status bar don't request focus + return MouseHandler.resultOf(args.event(), consumed, null, null); + }; + } + + private StatusItem itemAt(int x, int y) { + Rectangle rect = getRect(); + if (!rect.contains(x, y)) { + return null; + } + int ix = 0; + for (StatusItem item : items) { + if (x < ix + item.getTitle().length()) { + return item; + } + ix += item.getTitle().length(); + } + return null; + } + + /** + * Sets items. + * + * @param items status items + */ + public void setItems(List items) { + this.items.clear(); + this.items.addAll(items); + } + + /** + * Gets a status items. + * + * @return the status items + */ + public List getItems() { + return items; + } + + /** + * {@link StatusItem} represents an item in a {@link StatusBarView}. + */ + public static class StatusItem { + + private String title; + private Runnable action; + + public StatusItem(String title) { + this(title, null); + } + + public StatusItem(String title, Runnable action) { + this.title = title; + this.action = action; + } + + public String getTitle() { + return title; + } + + public Runnable getAction() { + return action; + } + + public void setAction(Runnable action) { + this.action = action; + } + + } + + /** + * {@link ViewEventArgs} for {@link StatusBarViewOpenSelectedItemEvent}. + * + * @param item the status bar view item + */ + public record StatusBarViewItemEventArgs(StatusItem item) implements ViewEventArgs { + + public static StatusBarViewItemEventArgs of(StatusItem item) { + return new StatusBarViewItemEventArgs(item); + } + } + + /** + * {@link ViewEvent} indicating that selected item has been requested to open. + * + * @param view the view sending an event + * @param args the event args + */ + public record StatusBarViewOpenSelectedItemEvent(View view, StatusBarViewItemEventArgs args) implements ViewEvent { + + public static StatusBarViewOpenSelectedItemEvent of(View view, StatusItem item) { + return new StatusBarViewOpenSelectedItemEvent(view, StatusBarViewItemEventArgs.of(item)); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/View.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/View.java new file mode 100644 index 000000000..a4fb14fc7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/View.java @@ -0,0 +1,72 @@ +/* + * Copyright 2023 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 org.springframework.lang.Nullable; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.MouseHandler; + +/** + * Base interface for all views. Represents a visible element that can render + * itself and contains zero or more nested {@code Views}. + * + * @author Janne Valkealahti + */ +public interface View extends Control { + + /** + * Sets a layer index this {@code View} operates on. + * + * @param index the layer index + */ + void setLayer(int index); + + /** + * Called when {@code View} gets or loses a focus. + * + * @param view the view receiving focus + * @param focus flag if focus is received + */ + void focus(View view, boolean focus); + + /** + * Gets if this {@code View} has a focus. + * + * @return true if view has a focus + */ + boolean hasFocus(); + + /** + * Gets a {@link View} mouse {@link MouseHandler}. Can be {@code null} which + * indicates view will not handle any mouse events. + * + * @return a view mouse handler + * @see MouseHandler + */ + @Nullable + MouseHandler getMouseHandler(); + + /** + * Gets a {@link View} mouse {@link KeyHandler}. Can be {@code null} which + * indicates view will not handle any key events. + * + * @return a view mouse handler + * @see KeyHandler + */ + @Nullable + KeyHandler getKeyHandler(); + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewCommand.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewCommand.java new file mode 100644 index 000000000..c3c0982c7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewCommand.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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; + +/** + * Commands which can be performed by the application or bound to keys in a + * {@link View}. This class is a placeholder for constants of types usually + * needed in a views. We've chosen this not to be enumerable so that there + * would not be restrictions in an api's to use these types. + * + * @author Janne Valkealahti + */ +public final class ViewCommand { + + /** + * Move line up. Generic use in something where selection needs to be moved up. + */ + public static String LINE_UP = "LineUp"; + + /** + * Move line down. Generic use in something where selection needs to be moved + * down. + */ + public static String LINE_DOWN = "LineDown"; + + /** + * Open selected item. In a some sort of view where something can be selected + * and that active selected should be opened. + */ + public static String OPEN_SELECTED_ITEM = "OpenSelectedItem"; + + public static String SELECT = "Select"; + public static String LEFT = "Left"; + public static String RIGHT = "Right"; + public static String SELECTION_CHANGED = "SelectionChanged"; + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEvent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEvent.java new file mode 100644 index 000000000..d612c74f7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEvent.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023 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; + +/** + * Base interface for view events. + * + * @author Janne Valkealahti + */ +public interface ViewEvent { + + View view(); + + default ViewEventArgs args() { + return ViewEventArgs.EMPTY; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEventArgs.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEventArgs.java new file mode 100644 index 000000000..13d8191b7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewEventArgs.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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; + +/** + * Base interface for view event args. + * + * @author Janne Valkealahti + */ +public interface ViewEventArgs { + + public static final ViewEventArgs EMPTY = new ViewEventArgs() { + }; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractCell.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractCell.java new file mode 100644 index 000000000..8413cbd30 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractCell.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.control.Cell; + +public abstract class AbstractCell extends AbstractControl implements Cell { + + private boolean selected; + private T item; + private int style = -1; + private int foregroundColor = -1; + private int backgroundColor = -1; + + @Override + public T getItem() { + return item; + } + + @Override + public void setItem(T item) { + this.item = item; + } + + @Override + public boolean isSelected() { + return selected; + } + + @Override + public void updateSelected(boolean selected) { + this.selected = selected; + } + + @Override + public void setStyle(int style) { + this.style = style; + } + + @Override + public void setForegroundColor(int foregroundColor) { + this.foregroundColor = foregroundColor; + } + + @Override + public void setBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + } + + public int getStyle() { + return style; + } + + public int getForegroundColor() { + return foregroundColor; + } + + public int getBackgroundColor() { + return backgroundColor; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractControl.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractControl.java new file mode 100644 index 000000000..64deb8a3e --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/AbstractControl.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.control.Control; +import org.springframework.shell.component.view.geom.Rectangle; + +/** + * Base implementation of a {@link Control}. + * + * @author Janne Valkealahti + */ +public abstract class AbstractControl implements Control { + + private int x = 0; + private int y = 0; + private int width = 0; + private int height = 0; + + @Override + public void setRect(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public Rectangle getRect() { + return new Rectangle(x, y, width, height); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/ListCell.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/ListCell.java new file mode 100644 index 000000000..08344f58f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/cell/ListCell.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 org.springframework.shell.component.view.control.Cell; +import org.springframework.shell.component.view.control.ListView; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; + +/** + * The {@link Cell} type used within {@link ListView} instances + * + * @author Janne Valkealahti + */ +public class ListCell extends AbstractCell { + + protected String text; + + @Override + public void draw(Screen screen) { + Rectangle rect = getRect(); + Writer writer = screen.writerBuilder().style(getStyle()).color(getForegroundColor()).build(); + writer.text(text, rect.x(), rect.y()); + writer.background(rect, getBackgroundColor()); + } + + public void updateItem(T item) { + setItem(item); + this.text = item.toString(); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/DefaultEventLoop.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/DefaultEventLoop.java new file mode 100644 index 000000000..cf9bc69b5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/DefaultEventLoop.java @@ -0,0 +1,291 @@ +/* + * Copyright 2023 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.event; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.Many; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.control.ViewEvent; +import org.springframework.shell.component.view.event.processor.AnimationEventLoopProcessor; +import org.springframework.shell.component.view.event.processor.TaskEventLoopProcessor; +import org.springframework.shell.component.view.message.ShellMessageHeaderAccessor; +import org.springframework.shell.component.view.message.StaticShellMessageHeaderAccessor; +import org.springframework.util.Assert; + +/** + * Default implementation of an {@link EventLoop}. + * + * @author Janne Valkealahti + */ +public class DefaultEventLoop implements EventLoop { + + private final static Logger log = LoggerFactory.getLogger(DefaultEventLoop.class); + private final Queue> messageQueue = new PriorityQueue<>(MessageComparator.comparingPriority()); + private final Many> many = Sinks.many().unicast().onBackpressureBuffer(messageQueue); + private Flux> sink; + // private final Sinks.Many subscribedSignal = Sinks.many().replay().limit(1); + private final Disposable.Composite disposables = Disposables.composite(); + private final Scheduler scheduler = Schedulers.boundedElastic(); + private volatile boolean active = true; + private final List processors; + + public DefaultEventLoop() { + this(null); + } + + public DefaultEventLoop(List processors) { + this.processors = new ArrayList<>(); + if (processors != null) { + this.processors.addAll(processors); + } + this.processors.add(new AnimationEventLoopProcessor()); + this.processors.add(new TaskEventLoopProcessor()); + init(); + } + + private void init() { + sink = many.asFlux() + .flatMap(m -> { + Flux> pm = null; + for (EventLoopProcessor processor : processors) { + if (processor.canProcess(m)) { + pm = processor.process(m); + break; + } + } + if (pm != null) { + return pm; + } + return Mono.just(m); + }) + .share(); + } + + @Override + public void dispatch(Message message) { + log.debug("dispatch {}", message); + if (!doSend(message, 1000)) { + log.warn("Failed to send message: {}", message); + } + } + + @Override + public void dispatch(Publisher> messages) { + subscribeTo(messages); + } + + @Override + public Flux> events() { + return sink + // .doFinally((s) -> subscribedSignal.tryEmitNext(sink.currentSubscriberCount() > 0)) + ; + } + + @Override + public Flux events(EventLoop.Type type, Class clazz) { + return events() + .filter(m -> type.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .map(m -> m.getPayload()) + .ofType(clazz); + } + + @Override + @SuppressWarnings("unchecked") + public Flux events(Type type, ParameterizedTypeReference typeRef) { + ResolvableType resolvableType = ResolvableType.forType(typeRef); + return (Flux) events() + .filter(m -> type.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .map(m -> m.getPayload()) + .ofType(resolvableType.getRawClass()); + } + + @Override + public Flux keyEvents() { + return events() + .filter(m -> EventLoop.Type.KEY.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .map(m -> m.getPayload()) + .ofType(KeyEvent.class); + } + + @Override + public Flux mouseEvents() { + return events() + .filter(m -> EventLoop.Type.MOUSE.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .map(m -> m.getPayload()) + .ofType(MouseEvent.class); + } + + @Override + public Flux viewEvents(Class clazz) { + return events(EventLoop.Type.VIEW, clazz); + } + + @Override + public Flux viewEvents(ParameterizedTypeReference typeRef) { + return events(EventLoop.Type.VIEW, typeRef); + } + + @Override + public Flux viewEvents(Class clazz, View filterBy) { + return events(EventLoop.Type.VIEW, clazz).filter(args -> args.view() == filterBy); + } + + @Override + public Flux viewEvents(ParameterizedTypeReference typeRef, View filterBy) { + return events(EventLoop.Type.VIEW, typeRef).filter(args -> args.view() == filterBy); + } + + @Override + public void onDestroy(Disposable disposable) { + disposables.add(disposable); + } + + + // @Override + // public void subcribe(Flux> messages) { + // upstreamSubscriptions.add( + // messages + // // .delaySubscription(subscribedSignal.asFlux().filter(Boolean::booleanValue).next()) + // .subscribe() + // ); + // } + + private boolean doSend(Message message, long timeout) { + Assert.state(this.active && this.many.currentSubscriberCount() > 0, + () -> "The [" + this + "] doesn't have subscribers to accept messages"); + long remainingTime = 0; + if (timeout > 0) { + remainingTime = timeout; + } + long parkTimeout = 10; + long parkTimeoutNs = TimeUnit.MILLISECONDS.toNanos(parkTimeout); + while (this.active && !tryEmitMessage(message)) { + remainingTime -= parkTimeout; + if (timeout >= 0 && remainingTime <= 0) { + return false; + } + LockSupport.parkNanos(parkTimeoutNs); + } + return true; + } + + private boolean tryEmitMessage(Message message) { + return switch (many.tryEmitNext(message)) { + case OK -> true; + case FAIL_NON_SERIALIZED, FAIL_OVERFLOW -> false; + case FAIL_ZERO_SUBSCRIBER -> + throw new IllegalStateException("The [" + this + "] doesn't have subscribers to accept messages"); + case FAIL_TERMINATED, FAIL_CANCELLED -> + throw new IllegalStateException("Cannot emit messages into the cancelled or terminated sink: " + many); + }; + } + + // public void subscribe(Subscriber> subscriber) { + // sink.asFlux() + // .doFinally((s) -> subscribedSignal.tryEmitNext(sink.currentSubscriberCount() > 0)) + // .share() + // .subscribe(subscriber); + + // upstreamSubscriptions.add( + // Mono.fromCallable(() -> sink.currentSubscriberCount() > 0) + // .filter(Boolean::booleanValue) + // .doOnNext(subscribedSignal::tryEmitNext) + // .repeatWhenEmpty((repeat) -> + // active ? repeat.delayElements(Duration.ofMillis(100)) : repeat) + // .subscribe()); + // } + + public void subscribeTo(Publisher> publisher) { + disposables.add( + Flux.from(publisher) + // .delaySubscription(subscribedSignal.asFlux().filter(Boolean::booleanValue).next()) + .publishOn(scheduler) + .flatMap((message) -> + Mono.just(message) + .handle((messageToHandle, syncSink) -> sendReactiveMessage(messageToHandle)) + .contextWrite(StaticShellMessageHeaderAccessor.getReactorContext(message)) + ) + .contextCapture() + .subscribe()); + } + + private void sendReactiveMessage(Message message) { + Message messageToSend = message; + // We have just restored Reactor context, so no need in a header anymore. + if (messageToSend.getHeaders().containsKey(ShellMessageHeaderAccessor.REACTOR_CONTEXT)) { + messageToSend = + MessageBuilder.fromMessage(message) + .removeHeader(ShellMessageHeaderAccessor.REACTOR_CONTEXT) + .build(); + } + try { + dispatch(messageToSend); + // if (!doSend(messageToSend, 1000)) { + // log.warn("Failed to send message: {}", messageToSend); + // } + } + catch (Exception ex) { + log.warn("Error during processing event: {}", messageToSend); + } + } + + public void destroy() { + this.active = false; + this.disposables.dispose(); + // this.subscribedSignal.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST); + this.many.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST); + this.scheduler.dispose(); + } + + private static class MessageComparator implements Comparator> { + + @Override + public int compare(Message left, Message right) { + Integer l = StaticShellMessageHeaderAccessor.getPriority(left); + Integer r = StaticShellMessageHeaderAccessor.getPriority(right); + if (l != null && r != null) { + return l.compareTo(r); + } + return 0; + } + + static Comparator> comparingPriority() { + return new MessageComparator(); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/EventLoop.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/EventLoop.java new file mode 100644 index 000000000..dc6d7fa40 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/EventLoop.java @@ -0,0 +1,211 @@ +/* + * Copyright 2023 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.event; + +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.messaging.Message; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.control.ViewEvent; + +/** + * {@code EventLoop} is a central place where all eventing will be orchestrated + * for a lifecycle of a component. Orchestration is usually needed around timings + * of redraws and and component state updates. + * + * Generic message type is a Spring {@link Message} and it's up to an {@code EventLoop} + * implementation how those are processed. + * + * @author Janne Valkealahti + */ +public interface EventLoop { + + /** + * Return a {@link Flux} of {@link Message} events. When subscribed events will + * be received until disposed or {@code EventLoop} terminates. + * + * @return the events from an event loop + */ + Flux> events(); + + /** + * Specialisation of {@link #events()} which returns type safe + * {@link KeyEvent}s. + * + * @return the key events from an event loop + */ + Flux keyEvents(); + + /** + * Specialisation of {@link #events()} which returns type safe + * {@link MouseEvent}s. + * + * @return the mouse events from an event loop + */ + Flux mouseEvents(); + + /** + * Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s. + * + * @param the type to expect + * @param clazz the type class to filter + * @return the filtered events from an event loop + */ + Flux viewEvents(Class clazz); + + /** + * Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s. + * + * @param the type to expect + * @param typeRef the parameterized type to filter + * @return the filtered events from an event loop + */ + Flux viewEvents(ParameterizedTypeReference typeRef); + + /** + * Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s. + * + * @param the type to expect + * @param clazz the type class to filter + * @param filterBy the view to filter + * @return the filtered events from an event loop + */ + Flux viewEvents(Class clazz, View filterBy); + + /** + * Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s. + * + * @param the type to expect + * @param typeRef the parameterized type to filter + * @param filterBy the view to filter + * @return the filtered events from an event loop + */ + Flux viewEvents(ParameterizedTypeReference typeRef, View filterBy); + + /** + * Specialisation of {@link #events()} which returns type safe + * stream filtered by given eventloop message type and message + * payload class type. + * + * @param the type to expect + * @param type the eventloop message type to filter + * @param clazz the type class to filter + * @return the filtered events from an event loop + */ + Flux events(EventLoop.Type type, Class clazz); + + /** + * Specialisation of {@link #events()} which returns type safe + * stream filtered by given eventloop message type and message + * payload class type. + * + * @param the type to expect + * @param type the eventloop message type to filter + * @param typeRef the parameterized type to filter + * @return the filtered events from an event loop + */ + Flux events(EventLoop.Type type, ParameterizedTypeReference typeRef); + + /** + * Dispatch {@link Message}s into an {@code EventLoop} from a {@link Publisher}. + * Usually type is either {@link Mono} or {@link Flux}. + * + * @param messages the messages to dispatch + */ + void dispatch(Publisher> messages); + + /** + * Dispatch a {@link Message} into an {@code EventLoop}. + * + * @param message the message to dispatch + */ + void dispatch(Message message); + + /** + * Register {@link Disposable} to get disposed when event loop terminates. + * + * @param disposable a disposable to dispose + */ + void onDestroy(Disposable disposable); + + /** + * Type of an events handled by an {@code EventLoop}. + */ + enum Type { + + /** + * Signals dispatched from a terminal. + */ + SIGNAL, + + /** + * Key bindings from a terminal. + */ + KEY, + + /** + * Mouse bindings from a terminal. + */ + MOUSE, + + /** + * System bindinds like redraw. + */ + SYSTEM, + + /** + * View bindinds from views. + */ + VIEW, + + /** + * User bindinds for custom events. + */ + USER, + + TASK + } + + /** + * Contract to process event loop messages, possibly translating an event into + * some other type of event or events. + */ + interface EventLoopProcessor { + + /** + * Checks if this processor can process an event. If this method returns {@code true} + * it's quaranteed that {@link #process(Message)} is called to resolve translation + * of a message. + * + * @param message the message + * @return true if processor can process an event + */ + boolean canProcess(Message message); + + /** + * Process a message and transform it into a new {@link Flux} of {@link Message} + * instances. + * + * @param message the message to process + * @return a flux of messages + */ + Flux> process(Message message); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java new file mode 100644 index 000000000..d5275938a --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java @@ -0,0 +1,81 @@ +/* + * Copyright 2023 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.event; + +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.InfoCmp.Capability; + +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.util.Assert; + +import static org.jline.keymap.KeyMap.alt; +import static org.jline.keymap.KeyMap.ctrl; +import static org.jline.keymap.KeyMap.del; +import static org.jline.keymap.KeyMap.key; +import static org.jline.keymap.KeyMap.translate; + +public class KeyBinder { + + private final Terminal terminal; + + public KeyBinder(Terminal terminal) { + Assert.notNull(terminal, "terminal must be set"); + this.terminal = terminal; + } + + public void bindAll(KeyMap keyMap) { + + keyMap.setUnicode(Key.Unicode); + + for (char i = 32; i < KeyMap.KEYMAP_LENGTH - 1; i++) { + keyMap.bind(KeyEvent.Key.Char, Character.toString(i)); + } + + keyMap.bind(KeyEvent.Key.q | KeyEvent.KeyMask.CtrlMask, ctrl('q')); + + keyMap.bind(KeyEvent.Key.Mouse, key(terminal, Capability.key_mouse)); + + keyMap.bind(KeyEvent.Key.Enter, "\r"); + keyMap.bind(KeyEvent.Key.Backspace, del()); + keyMap.bind(KeyEvent.Key.Delete, key(terminal, Capability.key_dc)); + keyMap.bind(KeyEvent.Key.Tab, "\t"); + keyMap.bind(KeyEvent.Key.Backtab, key(terminal, Capability.key_btab)); + + + keyMap.bind(KeyEvent.Key.CursorLeft, key(terminal, Capability.key_left)); + keyMap.bind(KeyEvent.Key.CursorRight, key(terminal, Capability.key_right)); + keyMap.bind(KeyEvent.Key.CursorUp, key(terminal, Capability.key_up)); + keyMap.bind(KeyEvent.Key.CursorDown, key(terminal, Capability.key_down)); + + keyMap.bind(KeyEvent.Key.CursorLeft | KeyEvent.KeyMask.AltMask, alt(key(terminal, Capability.key_left))); + keyMap.bind(KeyEvent.Key.CursorRight | KeyEvent.KeyMask.AltMask, alt(key(terminal, Capability.key_right))); + keyMap.bind(KeyEvent.Key.CursorUp | KeyEvent.KeyMask.AltMask, alt(key(terminal, Capability.key_up))); + keyMap.bind(KeyEvent.Key.CursorDown | KeyEvent.KeyMask.AltMask, alt(key(terminal, Capability.key_down))); + + keyMap.bind(KeyEvent.Key.CursorLeft | KeyEvent.KeyMask.CtrlMask, translate("^[[1;5D")); + keyMap.bind(KeyEvent.Key.CursorRight | KeyEvent.KeyMask.CtrlMask, translate("^[[1;5C")); + keyMap.bind(KeyEvent.Key.CursorUp | KeyEvent.KeyMask.CtrlMask, translate("^[[1;5A")); + keyMap.bind(KeyEvent.Key.CursorDown | KeyEvent.KeyMask.CtrlMask, translate("^[[1;5B")); + + keyMap.bind(KeyEvent.Key.CursorLeft | KeyEvent.KeyMask.ShiftMask, translate("^[[1;2D")); + keyMap.bind(KeyEvent.Key.CursorRight | KeyEvent.KeyMask.ShiftMask, translate("^[[1;2C")); + keyMap.bind(KeyEvent.Key.CursorUp | KeyEvent.KeyMask.ShiftMask, translate("^[[1;2A")); + keyMap.bind(KeyEvent.Key.CursorDown | KeyEvent.KeyMask.ShiftMask, translate("^[[1;2B")); + + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumer.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumer.java new file mode 100644 index 000000000..558eda254 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumer.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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.event; + +import java.util.function.Consumer; + +@FunctionalInterface +public interface KeyBindingConsumer extends Consumer { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumerArgs.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumerArgs.java new file mode 100644 index 000000000..5db484088 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBindingConsumerArgs.java @@ -0,0 +1,19 @@ +/* + * Copyright 2023 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.event; + +public record KeyBindingConsumerArgs(KeyBindingConsumer consumer, KeyEvent event) { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java new file mode 100644 index 000000000..46f706c72 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java @@ -0,0 +1,129 @@ +/* + * Copyright 2023 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.event; + +/** + * + * mask special keys unicode keys ascii keys + * [ ] [ ] [ ] [ ] + * 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00 + * + * + */ +public record KeyEvent(int key) { + + public static KeyEvent of(int key) { + return new KeyEvent(key); + } + + + public boolean hasCtrl() { + return ((key >> 30) & 1) == 1; + } + + public int getPlainKey() { + return key & ~0xFFF00000; + } + + public boolean isKey(int match) { + return (key & ~0xF0000000) == match; + } + + public boolean isKey() { + return (key & ~KeyMask.CharMask) == 0; + } + + public static class Key { + + public static final int A = 65; + public static final int B = 66; + public static final int C = 67; + public static final int D = 68; + public static final int E = 69; + public static final int F = 70; + public static final int G = 71; + public static final int H = 72; + public static final int I = 73; + public static final int J = 74; + public static final int K = 75; + public static final int L = 76; + public static final int M = 77; + public static final int N = 78; + public static final int O = 79; + public static final int P = 80; + public static final int Q = 81; + public static final int R = 82; + public static final int S = 83; + public static final int T = 84; + public static final int U = 85; + public static final int V = 86; + public static final int W = 87; + public static final int X = 88; + public static final int Y = 89; + public static final int Z = 90; + + public static final int a = 97; + public static final int b = 98; + public static final int c = 99; + public static final int d = 100; + public static final int e = 101; + public static final int f = 102; + public static final int g = 103; + public static final int h = 104; + public static final int i = 105; + public static final int j = 106; + public static final int k = 107; + public static final int l = 108; + public static final int m = 109; + public static final int n = 110; + public static final int o = 111; + public static final int p = 112; + public static final int q = 113; + public static final int r = 114; + public static final int s = 115; + public static final int t = 116; + public static final int u = 117; + public static final int v = 118; + public static final int w = 119; + public static final int x = 120; + public static final int y = 121; + public static final int z = 122; + + public static final int CursorUp = 0x100000; + public static final int CursorDown = 0x100001; + public static final int CursorLeft = 0x100002; + public static final int CursorRight = 0x100003; + public static final int Enter = 0x100004; + public static final int Backspace = 0x100005; + public static final int Delete = 0x100006; + public static final int Tab = 0x100007; + public static final int Backtab = 0x100008; + + public static final int Char = 0x1000000; + public static final int Mouse = 0x1000001; + public static final int Unicode = 0x1000002; + + } + + public static class KeyMask { + public static final int CharMask = 0x000fffff; + public static final int SpecialMask = 0xfff00000; + public static final int ShiftMask = 0x10000000; + public static final int CtrlMask = 0x40000000; + public static final int AltMask = 0x80000000; + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyHandler.java new file mode 100644 index 000000000..316674d16 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyHandler.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 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.event; + +import java.util.function.Predicate; + +import org.springframework.lang.Nullable; +import org.springframework.shell.component.view.control.View; + +/** + * Handles Key events in a form of {@link KeyHandlerArgs} and returns + * {@link KeyHandlerResult}. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface KeyHandler { + + /** + * Handle Key event wrapped in a {@link KeyHandlerArgs}. + * + * @param args the Key handler arguments + * @return a handler result + */ + KeyHandlerResult handle(KeyHandlerArgs args); + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} handler if {@code predicate} against result from + * {@code this} matches. + * + * @param other the handler to handle after this handler + * @param predicate the predicate test against results from this + * @return a composed handler + */ + default KeyHandler thenConditionally(KeyHandler other, Predicate predicate) { + return args -> { + KeyHandlerResult result = handle(args); + if (predicate.test(result)) { + return other.handle(args); + } + return result; + }; + } + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} if {@code this} consumed an event. + * + * @param other the handler to handle after this handler + * @return a composed handler + */ + default KeyHandler thenIfConsumed(KeyHandler other) { + return thenConditionally(other, result -> result.consumed()); + } + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} if {@code this} did not consume an event. + * + * @param other the handler to handle after this handler + * @return a composed handler + */ + default KeyHandler thenIfNotConsumed(KeyHandler other) { + return thenConditionally(other, result -> !result.consumed()); + } + + /** + * Returns a handler that always returns a non-consumed result. + * + * @return a handler that always returns a non-consumed result + */ + static KeyHandler neverConsume() { + return args -> resultOf(args.event(), false, null); + } + + /** + * Construct {@link KeyHandlerArgs} from a {@link KeyEvent}. + * + * @param event the Key event + * @return a Key handler args + */ + static KeyHandlerArgs argsOf(KeyEvent event) { + return new KeyHandlerArgs(event); + } + + /** + * Construct {@link KeyHandlerResult} from a {@link KeyEvent} and a + * {@link View}. + * + * @param event the Key event + * @param focus the view + * @return a Key handler result + */ + static KeyHandlerResult resultOf(KeyEvent event, boolean consumed, View focus) { + return new KeyHandlerResult(event, consumed, focus, null); + } + + /** + * Arguments for a {@link KeyHandler}. + * + * @param event the Key event + */ + record KeyHandlerArgs(KeyEvent event) { + } + + /** + * Result from a {@link KeyHandler}. + * + * @param event the Key event + * @param consumed flag telling if event was consumed + * @param focus the view which consumed an event + * @param capture the view which captured an event + */ + record KeyHandlerResult(@Nullable KeyEvent event, boolean consumed, @Nullable View focus, + @Nullable View capture) { + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumer.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumer.java new file mode 100644 index 000000000..950a86feb --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumer.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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.event; + +import java.util.function.Consumer; + +@FunctionalInterface +public interface MouseBindingConsumer extends Consumer { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumerArgs.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumerArgs.java new file mode 100644 index 000000000..123dba4d6 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseBindingConsumerArgs.java @@ -0,0 +1,19 @@ +/* + * Copyright 2023 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.event; + +public record MouseBindingConsumerArgs(MouseBindingConsumer consumer, MouseEvent event) { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseEvent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseEvent.java new file mode 100644 index 000000000..658b0d0f6 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseEvent.java @@ -0,0 +1,113 @@ +/* + * Copyright 2023 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.event; + +/** + * + * unused modifier button type + * [ ] [ ] [ ] [ ] + * 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00 + * + * + */ +public record MouseEvent(int x, int y, int mouse) { + + public static MouseEvent of(int x, int y, int mouse) { + return new MouseEvent(x, y, mouse); + } + + public static MouseEvent of(org.jline.terminal.MouseEvent event) { + int type = switch (event.getType()) { + case Released -> Type.Released; + case Pressed -> Type.Pressed; + case Wheel -> Type.Wheel; + case Moved -> Type.Moved; + case Dragged -> Type.Dragged; + default -> 0; + }; + int button = switch (event.getButton()) { + case NoButton -> Button.NoButton; + case Button1 -> Button.Button1; + case Button2 -> Button.Button2; + case Button3 -> Button.Button3; + case WheelUp -> Button.WheelUp; + case WheelDown -> Button.WheelDown; + default -> 0; + }; + int modifier = 0; + if (event.getModifiers() != null) { + if (event.getModifiers().contains(org.jline.terminal.MouseEvent.Modifier.Shift)) { + modifier |= Modifier.Shift; + } + if (event.getModifiers().contains(org.jline.terminal.MouseEvent.Modifier.Alt)) { + modifier |= Modifier.Alt; + } + if (event.getModifiers().contains(org.jline.terminal.MouseEvent.Modifier.Control)) { + modifier |= Modifier.Control; + } + } + return of(event.getX(), event.getY(), type | button | modifier); + } + + public boolean hasType() { + return (mouse & MouseMask.TypeMask) != 0; + } + + public boolean hasButton() { + return (mouse & MouseMask.ButtonMask) != 0; + } + + public boolean hasModifier() { + return (mouse & MouseMask.ModifierMask) != 0; + } + + public boolean has(int mask) { + return (mouse & mask) == mask; + } + + public static class Type { + public static final int Released = 0x00000001; + public static final int Pressed = 0x00000002; + public static final int Wheel = 0x00000004; + public static final int Moved = 0x00000008; + public static final int Dragged = 0x00000010; + } + + public static class Button { + public static final int NoButton = 0x00000020; + public static final int Button1 = 0x00000040; + public static final int Button2 = 0x00000080; + public static final int Button3 = 0x00000100; + public static final int WheelUp = 0x00000200; + public static final int WheelDown = 0x00000400; + } + + public static class Modifier { + public static final int Shift = 0x00000800; + public static final int Alt = 0x00001000; + public static final int Control = 0x00002000; + } + + public static class MouseMask { + // bits 0-4 + public static final int TypeMask = 0x0000001f; + // bits 5-10 + public static final int ButtonMask = 0x000007e0; + // bits 11-13 + public static final int ModifierMask = 0x00003800; + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseHandler.java new file mode 100644 index 000000000..20cc60316 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/MouseHandler.java @@ -0,0 +1,135 @@ +/* + * Copyright 2023 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.event; + +import java.util.function.Predicate; + +import org.springframework.lang.Nullable; +import org.springframework.shell.component.view.control.View; + +/** + * Handles mouse events in a form of {@link MouseHandlerArgs} and returns + * {@link MouseHandlerResult}. Typically used in a {@link View}. + * + * {@link MouseHandler} itself don't define any restrictions how it's used. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface MouseHandler { + + /** + * Handle mouse event wrapped in a {@link MouseHandlerArgs}. + * + * @param args the mouse handler arguments + * @return a handler result + */ + MouseHandlerResult handle(MouseHandlerArgs args); + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} handler if {@code predicate} against result from + * {@code this} matches. + * + * @param other the handler to handle after this handler + * @param predicate the predicate test against results from this + * @return a composed handler + */ + default MouseHandler thenConditionally(MouseHandler other, Predicate predicate) { + return args -> { + MouseHandlerResult result = handle(args); + if (predicate.test(result)) { + return other.handle(args); + } + return result; + }; + } + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} if {@code this} consumed an event. + * + * @param other the handler to handle after this handler + * @return a composed handler + */ + default MouseHandler thenIfConsumed(MouseHandler other) { + return thenConditionally(other, result -> result.consumed()); + } + + /** + * Returns a composed handler that first handles {@code this} handler and then + * handles {@code other} if {@code this} did not consume an event. + * + * @param other the handler to handle after this handler + * @return a composed handler + */ + default MouseHandler thenIfNotConsumed(MouseHandler other) { + return thenConditionally(other, result -> !result.consumed()); + } + + /** + * Returns a handler that always returns a non-consumed result. + * + * @return a handler that always returns a non-consumed result + */ + static MouseHandler neverConsume() { + return args -> resultOf(args.event(), false, null, null); + } + + /** + * Construct {@link MouseHandlerArgs} from a {@link MouseEvent}. + * + * @param event the mouse event + * @return a mouse handler args + */ + static MouseHandlerArgs argsOf(MouseEvent event) { + return new MouseHandlerArgs(event); + } + + /** + * Arguments for a {@link MouseHandler}. + * + * @param event the mouse event + */ + record MouseHandlerArgs(MouseEvent event) { + } + + /** + * Construct {@link MouseHandlerResult} from a {@link MouseEvent} and a + * {@link View}. + * + * @param event the mouse event + * @param consumed flag telling if event was consumed + * @param focus the view which is requesting focus + * @param capture the view which captured an event + * @return a mouse handler result + */ + static MouseHandlerResult resultOf(MouseEvent event, boolean consumed, View focus, View capture) { + return new MouseHandlerResult(event, consumed, focus, capture); + } + + /** + * Result from a {@link MouseHandler}. + * + * @param event the mouse event + * @param consumed flag telling if event was consumed + * @param focus the view which is requesting focus + * @param capture the view which captured an event + */ + record MouseHandlerResult(@Nullable MouseEvent event, boolean consumed, @Nullable View focus, + @Nullable View capture) { + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/AnimationEventLoopProcessor.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/AnimationEventLoopProcessor.java new file mode 100644 index 000000000..6026e536b --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/AnimationEventLoopProcessor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 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.event.processor; + +import java.time.Duration; + +import reactor.core.publisher.Flux; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.EventLoop.EventLoopProcessor; +import org.springframework.shell.component.view.message.ShellMessageHeaderAccessor; +import org.springframework.shell.component.view.message.StaticShellMessageHeaderAccessor; + +/** + * {@link EventLoopProcessor} converting incoming message into animation tick + * messages. + * + * @author Janne Valkealahti + */ +public class AnimationEventLoopProcessor implements EventLoopProcessor { + + @Override + public boolean canProcess(Message message) { + if (EventLoop.Type.SYSTEM.equals(StaticShellMessageHeaderAccessor.getEventType(message))) { + if (message.getHeaders().containsKey("animationstart")) { + return true; + } + } + return false; + } + + @Override + public Flux> process(Message message) { + return Flux.range(0, 40) + .delayElements(Duration.ofMillis(100)) + .map(i -> { + return MessageBuilder + .withPayload(i) + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.SYSTEM) + .setHeader("animationtick", true) + .setHeader("animationfrom", 0) + .setHeader("animationto", 9) + .build(); + }); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/TaskEventLoopProcessor.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/TaskEventLoopProcessor.java new file mode 100644 index 000000000..39af66196 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/processor/TaskEventLoopProcessor.java @@ -0,0 +1,91 @@ +/* + * Copyright 2023 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.event.processor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.KeyBindingConsumerArgs; +import org.springframework.shell.component.view.event.EventLoop.EventLoopProcessor; +import org.springframework.shell.component.view.event.MouseBindingConsumerArgs; +import org.springframework.shell.component.view.message.StaticShellMessageHeaderAccessor; + +public class TaskEventLoopProcessor implements EventLoopProcessor { + + @Override + public boolean canProcess(Message message) { + if (EventLoop.Type.TASK.equals(StaticShellMessageHeaderAccessor.getEventType(message))) { + Object payload = message.getPayload(); + if (payload instanceof Runnable r) { + return true; + } + else if(payload instanceof KeyBindingConsumerArgs) { + return true; + } + else if(payload instanceof MouseBindingConsumerArgs) { + return true; + } + } + return false; + } + + @Override + public Flux> process(Message message) { + Object payload = message.getPayload(); + if (payload instanceof Runnable r) { + return processRunnable(message); + } + else if(payload instanceof KeyBindingConsumerArgs) { + return processKeyConsumer(message); + } + else if(payload instanceof MouseBindingConsumerArgs) { + return processMouseConsumer(message); + } + // should not happen + throw new IllegalArgumentException(); + } + + private Flux> processRunnable(Message message) { + return Mono.just(message.getPayload()) + .ofType(Runnable.class) + .flatMap(Mono::fromRunnable) + .then(Mono.just(MessageBuilder.withPayload(new Object()).build())) + .flux(); + } + + private Flux> processMouseConsumer(Message message) { + return Mono.just(message.getPayload()) + .ofType(MouseBindingConsumerArgs.class) + .flatMap(args -> Mono.fromRunnable(() -> { + args.consumer().accept(args.event()); + })) + .then(Mono.just(MessageBuilder.withPayload(new Object()).build())) + .flux(); + } + + private Flux> processKeyConsumer(Message message) { + return Mono.just(message.getPayload()) + .ofType(KeyBindingConsumerArgs.class) + .flatMap(args -> Mono.fromRunnable(() -> { + args.consumer().accept(args.event()); + })) + .then(Mono.just(MessageBuilder.withPayload(new Object()).build())) + .flux(); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Dimension.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Dimension.java new file mode 100644 index 000000000..80597676c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Dimension.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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.geom; + +/** + * Record representing dimensions {@code width} and {@code height}. + */ +public record Dimension(int width, int height) { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/HorizontalAlign.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/HorizontalAlign.java new file mode 100644 index 000000000..fc0c35780 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/HorizontalAlign.java @@ -0,0 +1,20 @@ +/* + * Copyright 2023 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.geom; + +public enum HorizontalAlign { + LEFT, CENTER, RIGHT, +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Position.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Position.java new file mode 100644 index 000000000..3189713c2 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Position.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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.geom; + +/** + * Record representing position {@code x} and {@code y}. + */ +public record Position(int x, int y) { +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Rectangle.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Rectangle.java new file mode 100644 index 000000000..35f2fcfa5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/Rectangle.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 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.geom; + +/** + * Record representing coordinates {@code x}, {@code y} and its {@code width} + * and {@code height}. + */ +public record Rectangle(int x, int y, int width, int height) { + + public boolean contains(int X, int Y) { + int w = this.width; + int h = this.height; + if ((w | h) < 0) { + return false; + } + int x = this.x; + int y = this.y; + if (X < x || Y < y) { + return false; + } + w += x; + h += y; + return ((w < x || w > X) && (h < y || h > Y)); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/VerticalAlign.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/VerticalAlign.java new file mode 100644 index 000000000..4d8142b1f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/geom/VerticalAlign.java @@ -0,0 +1,20 @@ +/* + * Copyright 2023 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.geom; + +public enum VerticalAlign { + TOP, CENTER, BOTTOM +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageBuilder.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageBuilder.java new file mode 100644 index 000000000..b3cb9a3f5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageBuilder.java @@ -0,0 +1,121 @@ +/* + * Copyright 2023 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.message; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.util.Assert; + +/** + * Shell spesific message builder. + * + * @param the payload type. + * + * @author Janne Valkealahti + */ +public final class ShellMessageBuilder { + + private final T payload; + private final ShellMessageHeaderAccessor headerAccessor; + @Nullable + private final Message originalMessage; + + + private ShellMessageBuilder(T payload, @Nullable Message originalMessage) { + Assert.notNull(payload, "payload must not be null"); + this.payload = payload; + this.originalMessage = originalMessage; + this.headerAccessor = new ShellMessageHeaderAccessor(originalMessage); + // if (originalMessage != null) { + // this.modified = (!this.payload.equals(originalMessage.getPayload())); + // } + } + + /** + * Create a builder for a new {@link Message} instance with the provided payload. + * + * @param The type of the payload. + * @param payload the payload for the new message + * @return A ShellMessageBuilder. + */ + public static ShellMessageBuilder withPayload(T payload) { + return new ShellMessageBuilder<>(payload, null); + } + + /** + * Create a {@code redraw} message. + * + * @return a redraw message + */ + public static Message ofRedraw() { + return new ShellMessageBuilder<>("redraw", null) + .setEventType(EventLoop.Type.SYSTEM) + .setPriority(0) + .build(); + } + + /** + * Create a message of a {@link MouseEvent}. + * + * @param event the event type + * @return a message with {@link MouseEvent} as a payload + */ + public static Message ofMouseEvent(MouseEvent event) { + return new ShellMessageBuilder<>(event, null) + .setEventType(EventLoop.Type.MOUSE) + .build(); + } + + public static Message ofView(View view, Object args) { + return new ShellMessageBuilder<>(args, null) + .setEventType(EventLoop.Type.VIEW) + .setView(view) + .build(); + } + + public static Message ofViewFocus(String action, View view) { + return new ShellMessageBuilder<>(action, null) + .setEventType(EventLoop.Type.SYSTEM) + .setView(view) + .build(); + } + + public ShellMessageBuilder setPriority(Integer priority) { + return setHeader(ShellMessageHeaderAccessor.PRIORITY, priority); + } + + public ShellMessageBuilder setView(View view) { + return setHeader(ShellMessageHeaderAccessor.VIEW, view); + } + + public ShellMessageBuilder setEventType(EventLoop.Type type) { + return setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, type); + } + + public ShellMessageBuilder setHeader(String headerName, @Nullable Object headerValue) { + this.headerAccessor.setHeader(headerName, headerValue); + return this; + } + + public Message build() { + return new GenericMessage<>(this.payload, this.headerAccessor.toMap()); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageHeaderAccessor.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageHeaderAccessor.java new file mode 100644 index 000000000..397201808 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/ShellMessageHeaderAccessor.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023 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.message; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +import reactor.util.context.ContextView; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Adds standard shell Headers. + * + * @author Janne Valkealahti + */ +public class ShellMessageHeaderAccessor extends MessageHeaderAccessor { + + public static final String PRIORITY = "priority"; + + public static final String VIEW = "view"; + + /** + * Raw source message. + */ + public static final String REACTOR_CONTEXT = "reactorContext"; + + /** + * Raw source message. + */ + public static final String EVENT_TYPE = "eventType"; + + private static final BiFunction TYPE_VERIFY_MESSAGE_FUNCTION = + (name, trailer) -> "The '" + name + trailer; + + private Set readOnlyHeaders = new HashSet<>(); + + public ShellMessageHeaderAccessor(@Nullable Message message) { + super(message); + } + + /** + * Specify a list of headers which should be considered as read only and prohibited + * from being populated in the message. + * + * @param readOnlyHeaders the list of headers for {@code readOnly} mode. Defaults to + * {@link org.springframework.messaging.MessageHeaders#ID} and + * {@link org.springframework.messaging.MessageHeaders#TIMESTAMP}. + * @see #isReadOnly(String) + */ + public void setReadOnlyHeaders(String... readOnlyHeaders) { + Assert.noNullElements(readOnlyHeaders, "'readOnlyHeaders' must not be contain null items."); + if (!ObjectUtils.isEmpty(readOnlyHeaders)) { + this.readOnlyHeaders = new HashSet<>(Arrays.asList(readOnlyHeaders)); + } + } + + @Nullable + public Integer getPriority() { + Number priority = getHeader(PRIORITY, Number.class); + return (priority != null ? priority.intValue() : null); + } + + @Nullable + public View getView() { + View view = getHeader(VIEW, View.class); + return view; + } + + /** + * Get a {@link ContextView} header if present. + * + * @return the {@link ContextView} header if present. + */ + @Nullable + public ContextView getReactorContext() { + return getHeader(REACTOR_CONTEXT, ContextView.class); + } + + /** + * Get a {@link EventLoop.Type} header if present. + * + * @return the {@link EventLoop.Type} header if present. + */ + @Nullable + public EventLoop.Type getEventType() { + return getHeader(EVENT_TYPE, EventLoop.Type.class); + } + + @SuppressWarnings("unchecked") + @Nullable + public T getHeader(String key, Class type) { + Object value = getHeader(key); + if (value == null) { + return null; + } + if (!type.isAssignableFrom(value.getClass())) { + throw new IllegalArgumentException("Incorrect type specified for header '" + key + "'. Expected [" + type + + "] but actual type is [" + value.getClass() + "]"); + } + return (T) value; + } + + @Override + protected void verifyType(String headerName, Object headerValue) { + if (headerName != null && headerValue != null) { + super.verifyType(headerName, headerValue); + if (ShellMessageHeaderAccessor.PRIORITY.equals(headerName)) { + Assert.isTrue(Number.class.isAssignableFrom(headerValue.getClass()), + TYPE_VERIFY_MESSAGE_FUNCTION.apply(headerName, "' header value must be a Number.")); + } + } + } + + @Override + public boolean isReadOnly(String headerName) { + return super.isReadOnly(headerName) || this.readOnlyHeaders.contains(headerName); + } + + @Override + public Map toMap() { + if (ObjectUtils.isEmpty(this.readOnlyHeaders)) { + return super.toMap(); + } + else { + Map headers = super.toMap(); + for (String header : this.readOnlyHeaders) { + headers.remove(header); + } + return headers; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/StaticShellMessageHeaderAccessor.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/StaticShellMessageHeaderAccessor.java new file mode 100644 index 000000000..968e5b9cb --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/message/StaticShellMessageHeaderAccessor.java @@ -0,0 +1,98 @@ +/* + * Copyright 2023 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.message; + +import java.util.UUID; + +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.EventLoop; + +/** + * Lightweight type-safe header accessor avoiding object creation just to access + * a header. + * + * @author Janne Valkealahti + * + * @see ShellMessageHeaderAccessor + */ +public final class StaticShellMessageHeaderAccessor { + + private StaticShellMessageHeaderAccessor() { + } + + @Nullable + public static UUID getId(Message message) { + Object value = message.getHeaders().get(MessageHeaders.ID); + if (value == null) { + return null; + } + return (value instanceof UUID ? (UUID) value : UUID.fromString(value.toString())); + } + + @Nullable + public static Long getTimestamp(Message message) { + Object value = message.getHeaders().get(MessageHeaders.TIMESTAMP); + if (value == null) { + return null; + } + return (value instanceof Long ? (Long) value : Long.parseLong(value.toString())); + } + + @Nullable + public static Integer getPriority(Message message) { + Number priority = message.getHeaders().get(ShellMessageHeaderAccessor.PRIORITY, Number.class); + return (priority != null ? priority.intValue() : null); + } + + @Nullable + public static View getView(Message message) { + View view = message.getHeaders().get(ShellMessageHeaderAccessor.VIEW, View.class); + return view; + } + + /** + * Get a {@link ContextView} header if present. + * + * @param message the message to get a header from. + * @return the {@link ContextView} header if present. + */ + public static ContextView getReactorContext(Message message) { + ContextView reactorContext = message.getHeaders() + .get(ShellMessageHeaderAccessor.REACTOR_CONTEXT, ContextView.class); + if (reactorContext == null) { + reactorContext = Context.empty(); + } + return reactorContext; + } + + /** + * Get a {@link EventLoop.Type} header if present. + * + * @param message the message to get a header from. + * @return the {@link EventLoop.Type} header if present. + */ + public static EventLoop.Type getEventType(Message message) { + EventLoop.Type eventType = message.getHeaders() + .get(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.class); + return eventType; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Color.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Color.java new file mode 100644 index 000000000..240a2cf19 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Color.java @@ -0,0 +1,443 @@ +/* + * Copyright 2023 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.screen; + +public class Color { + + // https://en.wikipedia.org/wiki/X11_color_names#Numbered_variants + public static final int ANTIQUEWHITE = 0xFAEBD7; + public static final int AQUAMARINE = 0x7FFFD4; + public static final int AZURE = 0xF0FFFF; + public static final int BISQUE = 0xFFE4C4; + public static final int BLUE = 0x0000FF; + public static final int BROWN = 0xA52A2A; + public static final int BURLYWOOD = 0xDEB887; + public static final int CADETBLUE = 0x5F9EA0; + public static final int CHARTREUSE = 0x7FFF00; + public static final int CHOCOLATE = 0xD2691E; + public static final int CORAL = 0xFF7F50; + public static final int CORNSILK = 0xFFF8DC; + public static final int CYAN = 0x00FFFF; + public static final int DARKGOLDENROD = 0xB8860B; + public static final int DARKOLIVE = 0x556B2F; + public static final int DARKORANGE = 0xFF8C00; + public static final int DARKORCHID = 0x9932CC; + public static final int DARKSEAGREEN = 0x8FBC8F; + public static final int DARKSLATEGRAY = 0x2F4F4F; + public static final int DEEPPINK = 0xFF1493; + public static final int DEEPSKYBLUE = 0x00BFFF; + public static final int DODGERBLUE = 0x1E90FF; + public static final int FIREBRICK = 0xB22222; + public static final int GOLD = 0xFFD700; + public static final int GOLDENROD = 0xDAA520; + public static final int GREEN = 0x00FF00; + public static final int HONEYDEW = 0xF0FFF0; + public static final int HOTPINK = 0xFF69B4; + public static final int INDIANRED = 0xCD5C5C; + public static final int IVORY = 0xFFFFF0; + public static final int KHAKI = 0xF0E68C; + public static final int LAVENDERBLUSH = 0xFFF0F5; + public static final int LEMONCHIFFON = 0xFFFACD; + public static final int LIGHTBLUE = 0xADD8E6; + public static final int LIGHTCYAN = 0xE0FFFF; + public static final int LIGHTGOLDENROD = 0xEEDD82; + public static final int LIGHTPINK = 0xFFB6C1; + public static final int LIGHTSALMON = 0xFFA07A; + public static final int LIGHTSKYBLUE = 0x87CEFA; + public static final int LIGHTSTEELBLUE = 0xB0C4DE; + public static final int LIGHTYELLOW = 0xFFFFE0; + public static final int MAGENTA = 0xFF00FF; + public static final int MAROON = 0xB03060; + public static final int MEDIUMORCHID = 0xBA55D3; + public static final int MEDIUMPURPLE = 0x9370DB; + public static final int MISTYROSE = 0xFFE4E1; + public static final int NAVAJOWHITE = 0xFFDEAD; + public static final int OLIVEDRAB = 0x6B8E23; + public static final int ORANGE = 0xFFA500; + public static final int ORCHID = 0xDA70D6; + public static final int PALEGREEN = 0x98FB98; + public static final int PALETURQUOISE = 0xAFEEEE; + public static final int PALEVIOLETRED = 0xDB7093; + public static final int PEACHPUFF = 0xFFDAB9; + public static final int PINK = 0xFFC0CB; + public static final int PLUM = 0xDDA0DD; + public static final int PURPLE = 0xA020F0; + public static final int RED = 0xFF0000; + public static final int ROSYBROWN = 0xBC8F8F; + public static final int ROYALBLUE = 0x4169E1; + public static final int SALMON = 0xFA8072; + public static final int SEAGREEN = 0x2E8B57; + public static final int SEASHELL = 0xFFF5EE; + public static final int SIENNA = 0xA0522D; + public static final int SKYBLUE = 0x87CEEB; + public static final int SLATEBLUE = 0x6A5ACD; + public static final int SLATEGRAY = 0x708090; + public static final int SNOW = 0xFFFAFA; + public static final int SPRINGGREEN = 0x00FF7F; + public static final int STEELBLUE = 0x4682B4; + public static final int TAN = 0xD2B48C; + public static final int THISTLE = 0xD8BFD8; + public static final int TOMATO = 0xFF6347; + public static final int TURQUOISE = 0x40E0D0; + public static final int VIOLETRED = 0xD02090; + public static final int WHEAT = 0xF5DEB3; + public static final int YELLOW = 0xFFFF00; + public static final int ANTIQUEWHITE1 = 0xFFEFDB; + public static final int AQUAMARINE1 = 0x7FFFD4; + public static final int AZURE1 = 0xF0FFFF; + public static final int BISQUE1 = 0xFFE4C4; + public static final int BLUE1 = 0x0000FF; + public static final int BROWN1 = 0xFF4040; + public static final int BURLYWOOD1 = 0xFFD39B; + public static final int CADETBLUE1 = 0x98F5FF; + public static final int CHARTREUSE1 = 0x7FFF00; + public static final int CHOCOLATE1 = 0xFF7F24; + public static final int CORAL1 = 0xFF7256; + public static final int CORNSILK1 = 0xFFF8DC; + public static final int CYAN1 = 0x00FFFF; + public static final int DARKGOLDENROD1 = 0xFFB90F; + public static final int DARKOLIVE1 = 0xCAFF70; + public static final int DARKORANGE1 = 0xFF7F00; + public static final int DARKORCHID1 = 0xBF3EFF; + public static final int DARKSEAGREEN1 = 0xC1FFC1; + public static final int DARKSLATEGRAY1 = 0x97FFFF; + public static final int DEEPPINK1 = 0xFF1493; + public static final int DEEPSKYBLUE1 = 0x00BFFF; + public static final int DODGERBLUE1 = 0x1E90FF; + public static final int FIREBRICK1 = 0xFF3030; + public static final int GOLD1 = 0xFFD700; + public static final int GOLDENROD1 = 0xFFC125; + public static final int GREEN1 = 0x00FF00; + public static final int HONEYDEW1 = 0xF0FFF0; + public static final int HOTPINK1 = 0xFF6EB4; + public static final int INDIANRED1 = 0xFF6A6A; + public static final int IVORY1 = 0xFFFFF0; + public static final int KHAKI1 = 0xFFF68F; + public static final int LAVENDERBLUSH1 = 0xFFF0F5; + public static final int LEMONCHIFFON1 = 0xFFFACD; + public static final int LIGHTBLUE1 = 0xBFEFFF; + public static final int LIGHTCYAN1 = 0xE0FFFF; + public static final int LIGHTGOLDENROD1 = 0xFFEC8B; + public static final int LIGHTPINK1 = 0xFFAEB9; + public static final int LIGHTSALMON1 = 0xFFA07A; + public static final int LIGHTSKYBLUE1 = 0xB0E2FF; + public static final int LIGHTSTEELBLUE1 = 0xCAE1FF; + public static final int LIGHTYELLOW1 = 0xFFFFE0; + public static final int MAGENTA1 = 0xFF00FF; + public static final int MAROON1 = 0xFF34B3; + public static final int MEDIUMORCHID1 = 0xE066FF; + public static final int MEDIUMPURPLE1 = 0xAB82FF; + public static final int MISTYROSE1 = 0xFFE4E1; + public static final int NAVAJOWHITE1 = 0xFFDEAD; + public static final int OLIVEDRAB1 = 0xC0FF3E; + public static final int ORANGE1 = 0xFFA500; + public static final int ORCHID1 = 0xFF83FA; + public static final int PALEGREEN1 = 0x9AFF9A; + public static final int PALETURQUOISE1 = 0xBBFFFF; + public static final int PALEVIOLETRED1 = 0xFF82AB; + public static final int PEACHPUFF1 = 0xFFDAB9; + public static final int PINK1 = 0xFFB5C5; + public static final int PLUM1 = 0xFFBBFF; + public static final int PURPLE1 = 0x9B30FF; + public static final int RED1 = 0xFF0000; + public static final int ROSYBROWN1 = 0xFFC1C1; + public static final int ROYALBLUE1 = 0x4876FF; + public static final int SALMON1 = 0xFF8C69; + public static final int SEAGREEN1 = 0x54FF9F; + public static final int SEASHELL1 = 0xFFF5EE; + public static final int SIENNA1 = 0xFF8247; + public static final int SKYBLUE1 = 0x87CEFF; + public static final int SLATEBLUE1 = 0x836FFF; + public static final int SLATEGRAY1 = 0xC6E2FF; + public static final int SNOW1 = 0xFFFAFA; + public static final int SPRINGGREEN1 = 0x00FF7F; + public static final int STEELBLUE1 = 0x63B8FF; + public static final int TAN1 = 0xFFA54F; + public static final int THISTLE1 = 0xFFE1FF; + public static final int TOMATO1 = 0xFF6347; + public static final int TURQUOISE1 = 0x00F5FF; + public static final int VIOLETRED1 = 0xFF3E96; + public static final int WHEAT1 = 0xFFE7BA; + public static final int YELLOW1 = 0xFFFF00; + public static final int ANTIQUEWHITE2 = 0xEEDFCC; + public static final int AQUAMARINE2 = 0x76EEC6; + public static final int AZURE2 = 0xE0EEEE; + public static final int BISQUE2 = 0xEED5B7; + public static final int BLUE2 = 0x0000EE; + public static final int BROWN2 = 0xEE3B3B; + public static final int BURLYWOOD2 = 0xEEC591; + public static final int CADETBLUE2 = 0x8EE5EE; + public static final int CHARTREUSE2 = 0x76EE00; + public static final int CHOCOLATE2 = 0xEE7621; + public static final int CORAL2 = 0xEE6A50; + public static final int CORNSILK2 = 0xEEE8CD; + public static final int CYAN2 = 0x00EEEE; + public static final int DARKGOLDENROD2 = 0xEEAD0E; + public static final int DARKOLIVE2 = 0xBCEE68; + public static final int DARKORANGE2 = 0xEE7600; + public static final int DARKORCHID2 = 0xB23AEE; + public static final int DARKSEAGREEN2 = 0xB4EEB4; + public static final int DARKSLATEGRAY2 = 0x8DEEEE; + public static final int DEEPPINK2 = 0xEE1289; + public static final int DEEPSKYBLUE2 = 0x00B2EE; + public static final int DODGERBLUE2 = 0x1C86EE; + public static final int FIREBRICK2 = 0xEE2C2C; + public static final int GOLD2 = 0xEEC900; + public static final int GOLDENROD2 = 0xEEB422; + public static final int GREEN2 = 0x00EE00; + public static final int HONEYDEW2 = 0xE0EEE0; + public static final int HOTPINK2 = 0xEE6AA7; + public static final int INDIANRED2 = 0xEE6363; + public static final int IVORY2 = 0xEEEEE0; + public static final int KHAKI2 = 0xEEE685; + public static final int LAVENDERBLUSH2 = 0xEEE0E5; + public static final int LEMONCHIFFON2 = 0xEEE9BF; + public static final int LIGHTBLUE2 = 0xB2DFEE; + public static final int LIGHTCYAN2 = 0xD1EEEE; + public static final int LIGHTGOLDENROD2 = 0xEEDC82; + public static final int LIGHTPINK2 = 0xEEA2AD; + public static final int LIGHTSALMON2 = 0xEE9572; + public static final int LIGHTSKYBLUE2 = 0xA4D3EE; + public static final int LIGHTSTEELBLUE2 = 0xBCD2EE; + public static final int LIGHTYELLOW2 = 0xEEEED1; + public static final int MAGENTA2 = 0xEE00EE; + public static final int MAROON2 = 0xEE30A7; + public static final int MEDIUMORCHID2 = 0xD15FEE; + public static final int MEDIUMPURPLE2 = 0x9F79EE; + public static final int MISTYROSE2 = 0xEED5D2; + public static final int NAVAJOWHITE2 = 0xEECFA1; + public static final int OLIVEDRAB2 = 0xB3EE3A; + public static final int ORANGE2 = 0xEE9A00; + public static final int ORCHID2 = 0xEE7AE9; + public static final int PALEGREEN2 = 0x90EE90; + public static final int PALETURQUOISE2 = 0xAEEEEE; + public static final int PALEVIOLETRED2 = 0xEE799F; + public static final int PEACHPUFF2 = 0xEECBAD; + public static final int PINK2 = 0xEEA9B8; + public static final int PLUM2 = 0xEEAEEE; + public static final int PURPLE2 = 0x912CEE; + public static final int RED2 = 0xEE0000; + public static final int ROSYBROWN2 = 0xEEB4B4; + public static final int ROYALBLUE2 = 0x436EEE; + public static final int SALMON2 = 0xEE8262; + public static final int SEAGREEN2 = 0x4EEE94; + public static final int SEASHELL2 = 0xEEE5DE; + public static final int SIENNA2 = 0xEE7942; + public static final int SKYBLUE2 = 0x7EC0EE; + public static final int SLATEBLUE2 = 0x7A67EE; + public static final int SLATEGRAY2 = 0xB9D3EE; + public static final int SNOW2 = 0xEEE9E9; + public static final int SPRINGGREEN2 = 0x00EE76; + public static final int STEELBLUE2 = 0x5CACEE; + public static final int TAN2 = 0xEE9A49; + public static final int THISTLE2 = 0xEED2EE; + public static final int TOMATO2 = 0xEE5C42; + public static final int TURQUOISE2 = 0x00E5EE; + public static final int VIOLETRED2 = 0xEE3A8C; + public static final int WHEAT2 = 0xEED8AE; + public static final int YELLOW2 = 0xEEEE00; + public static final int ANTIQUEWHITE3 = 0xCDC0B0; + public static final int AQUAMARINE3 = 0x66CDAA; + public static final int AZURE3 = 0xC1CDCD; + public static final int BISQUE3 = 0xCDB79E; + public static final int BLUE3 = 0x0000CD; + public static final int BROWN3 = 0xCD3333; + public static final int BURLYWOOD3 = 0xCDAA7D; + public static final int CADETBLUE3 = 0x7AC5CD; + public static final int CHARTREUSE3 = 0x66CD00; + public static final int CHOCOLATE3 = 0xCD661D; + public static final int CORAL3 = 0xCD5B45; + public static final int CORNSILK3 = 0xCDC8B1; + public static final int CYAN3 = 0x00CDCD; + public static final int DARKGOLDENROD3 = 0xCD950C; + public static final int DARKOLIVE3 = 0xA2CD5A; + public static final int DARKORANGE3 = 0xCD6600; + public static final int DARKORCHID3 = 0x9A32CD; + public static final int DARKSEAGREEN3 = 0x9BCD9B; + public static final int DARKSLATEGRAY3 = 0x79CDCD; + public static final int DEEPPINK3 = 0xCD1076; + public static final int DEEPSKYBLUE3 = 0x009ACD; + public static final int DODGERBLUE3 = 0x1874CD; + public static final int FIREBRICK3 = 0xCD2626; + public static final int GOLD3 = 0xCDAD00; + public static final int GOLDENROD3 = 0xCD9B1D; + public static final int GREEN3 = 0x00CD00; + public static final int HONEYDEW3 = 0xC1CDC1; + public static final int HOTPINK3 = 0xCD6090; + public static final int INDIANRED3 = 0xCD5555; + public static final int IVORY3 = 0xCDCDC1; + public static final int KHAKI3 = 0xCDC673; + public static final int LAVENDERBLUSH3 = 0xCDC1C5; + public static final int LEMONCHIFFON3 = 0xCDC9A5; + public static final int LIGHTBLUE3 = 0x9AC0CD; + public static final int LIGHTCYAN3 = 0xB4CDCD; + public static final int LIGHTGOLDENROD3 = 0xCDBE70; + public static final int LIGHTPINK3 = 0xCD8C95; + public static final int LIGHTSALMON3 = 0xCD8162; + public static final int LIGHTSKYBLUE3 = 0x8DB6CD; + public static final int LIGHTSTEELBLUE3 = 0xA2B5CD; + public static final int LIGHTYELLOW3 = 0xCDCDB4; + public static final int MAGENTA3 = 0xCD00CD; + public static final int MAROON3 = 0xCD2990; + public static final int MEDIUMORCHID3 = 0xB452CD; + public static final int MEDIUMPURPLE3 = 0x8968CD; + public static final int MISTYROSE3 = 0xCDB7B5; + public static final int NAVAJOWHITE3 = 0xCDB38B; + public static final int OLIVEDRAB3 = 0x9ACD32; + public static final int ORANGE3 = 0xCD8500; + public static final int ORCHID3 = 0xCD69C9; + public static final int PALEGREEN3 = 0x7CCD7C; + public static final int PALETURQUOISE3 = 0x96CDCD; + public static final int PALEVIOLETRED3 = 0xCD6889; + public static final int PEACHPUFF3 = 0xCDAF95; + public static final int PINK3 = 0xCD919E; + public static final int PLUM3 = 0xCD96CD; + public static final int PURPLE3 = 0x7D26CD; + public static final int RED3 = 0xCD0000; + public static final int ROSYBROWN3 = 0xCD9B9B; + public static final int ROYALBLUE3 = 0x3A5FCD; + public static final int SALMON3 = 0xCD7054; + public static final int SEAGREEN3 = 0x43CD80; + public static final int SEASHELL3 = 0xCDC5BF; + public static final int SIENNA3 = 0xCD6839; + public static final int SKYBLUE3 = 0x6CA6CD; + public static final int SLATEBLUE3 = 0x6959CD; + public static final int SLATEGRAY3 = 0x9FB6CD; + public static final int SNOW3 = 0xCDC9C9; + public static final int SPRINGGREEN3 = 0x00CD66; + public static final int STEELBLUE3 = 0x4F94CD; + public static final int TAN3 = 0xCD853F; + public static final int THISTLE3 = 0xCDB5CD; + public static final int TOMATO3 = 0xCD4F39; + public static final int TURQUOISE3 = 0x00C5CD; + public static final int VIOLETRED3 = 0xCD3278; + public static final int WHEAT3 = 0xCDBA96; + public static final int YELLOW3 = 0xCDCD00; + public static final int ANTIQUEWHITE4 = 0x8B8378; + public static final int AQUAMARINE4 = 0x458B74; + public static final int AZURE4 = 0x838B8B; + public static final int BISQUE4 = 0x8B7D6B; + public static final int BLUE4 = 0x00008B; + public static final int BROWN4 = 0x8B2323; + public static final int BURLYWOOD4 = 0x8B7355; + public static final int CADETBLUE4 = 0x53868B; + public static final int CHARTREUSE4 = 0x458B00; + public static final int CHOCOLATE4 = 0x8B4513; + public static final int CORAL4 = 0x8B3E2F; + public static final int CORNSILK4 = 0x8B8878; + public static final int CYAN4 = 0x008B8B; + public static final int DARKGOLDENROD4 = 0x8B6508; + public static final int DARKOLIVE4 = 0x6E8B3D; + public static final int DARKORANGE4 = 0x8B4500; + public static final int DARKORCHID4 = 0x68228B; + public static final int DARKSEAGREEN4 = 0x698B69; + public static final int DARKSLATEGRAY4 = 0x528B8B; + public static final int DEEPPINK4 = 0x8B0A50; + public static final int DEEPSKYBLUE4 = 0x00688B; + public static final int DODGERBLUE4 = 0x104E8B; + public static final int FIREBRICK4 = 0x8B1A1A; + public static final int GOLD4 = 0x8B7500; + public static final int GOLDENROD4 = 0x8B6914; + public static final int GREEN4 = 0x008B00; + public static final int HONEYDEW4 = 0x838B83; + public static final int HOTPINK4 = 0x8B3A62; + public static final int INDIANRED4 = 0x8B3A3A; + public static final int IVORY4 = 0x8B8B83; + public static final int KHAKI4 = 0x8B864E; + public static final int LAVENDERBLUSH4 = 0x8B8386; + public static final int LEMONCHIFFON4 = 0x8B8970; + public static final int LIGHTBLUE4 = 0x68838B; + public static final int LIGHTCYAN4 = 0x7A8B8B; + public static final int LIGHTGOLDENROD4 = 0x8B814C; + public static final int LIGHTPINK4 = 0x8B5F65; + public static final int LIGHTSALMON4 = 0x8B5742; + public static final int LIGHTSKYBLUE4 = 0x607B8B; + public static final int LIGHTSTEELBLUE4 = 0x6E7B8B; + public static final int LIGHTYELLOW4 = 0x8B8B7A; + public static final int MAGENTA4 = 0x8B008B; + public static final int MAROON4 = 0x8B1C62; + public static final int MEDIUMORCHID4 = 0x7A378B; + public static final int MEDIUMPURPLE4 = 0x5D478B; + public static final int MISTYROSE4 = 0x8B7D7B; + public static final int NAVAJOWHITE4 = 0x8B795E; + public static final int OLIVEDRAB4 = 0x698B22; + public static final int ORANGE4 = 0x8B5A00; + public static final int ORCHID4 = 0x8B4789; + public static final int PALEGREEN4 = 0x548B54; + public static final int PALETURQUOISE4 = 0x668B8B; + public static final int PALEVIOLETRED4 = 0x8B475D; + public static final int PEACHPUFF4 = 0x8B7765; + public static final int PINK4 = 0x8B636C; + public static final int PLUM4 = 0x8B668B; + public static final int PURPLE4 = 0x551A8B; + public static final int RED4 = 0x8B0000; + public static final int ROSYBROWN4 = 0x8B6969; + public static final int ROYALBLUE4 = 0x27408B; + public static final int SALMON4 = 0x8B4C39; + public static final int SEAGREEN4 = 0x2E8B57; + public static final int SEASHELL4 = 0x8B8682; + public static final int SIENNA4 = 0x8B4726; + public static final int SKYBLUE4 = 0x4A708B; + public static final int SLATEBLUE4 = 0x473C8B; + public static final int SLATEGRAY4 = 0x6C7B8B; + public static final int SNOW4 = 0x8B8989; + public static final int SPRINGGREEN4 = 0x008B45; + public static final int STEELBLUE4 = 0x36648B; + public static final int TAN4 = 0x8B5A2B; + public static final int THISTLE4 = 0x8B7B8B; + public static final int TOMATO4 = 0x8B3626; + public static final int TURQUOISE4 = 0x00868B; + public static final int VIOLETRED4 = 0x8B2252; + public static final int WHEAT4 = 0x8B7E66; + public static final int YELLOW4 = 0x8B8B00; + + // basic color types + public final static int WHITE = 0xffffff; + public final static int BLACK = 0x000000; + + // grays + public final static int GREY0 = 0x000000; + public final static int GREY3 = 0x080808; + public final static int GREY7 = 0x121212; + public final static int GREY11 = 0x1c1c1c; + public final static int GREY15 = 0x262626; + public final static int GREY19 = 0x303030; + public final static int GREY23 = 0x3a3a3a; + public final static int GREY27 = 0x444444; + public final static int GREY30 = 0x4e4e4e; + public final static int GREY35 = 0x585858; + public final static int GREY37 = 0x5f5f5f; + public final static int GREY39 = 0x626262; + public final static int GREY42 = 0x6c6c6c; + public final static int GREY46 = 0x767676; + public final static int GREY50 = 0x808080; + public final static int GREY53 = 0x878787; + public final static int GREY54 = 0x8a8a8a; + public final static int GREY58 = 0x949494; + public final static int GREY62 = 0x9e9e9e; + public final static int GREY63 = 0xaf87af; + public final static int GREY66 = 0xa8a8a8; + public final static int GREY69 = 0xafafaf; + public final static int GREY70 = 0xb2b2b2; + public final static int GREY74 = 0xbcbcbc; + public final static int GREY78 = 0xc6c6c6; + public final static int GREY82 = 0xd0d0d0; + public final static int GREY84 = 0xd7d7d7; + public final static int GREY85 = 0xdadada; + public final static int GREY89 = 0xe4e4e4; + public final static int GREY93 = 0xeeeeee; + public final static int GREY100 = 0xffffff; +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DefaultScreen.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DefaultScreen.java new file mode 100644 index 000000000..3a491dc20 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DefaultScreen.java @@ -0,0 +1,411 @@ +/* + * Copyright 2023 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.screen; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.Position; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.geom.VerticalAlign; +import org.springframework.util.Assert; + +/** + * Default implementation of a {@link Screen}. + * + * @author Janne Valkealahti + */ +public class DefaultScreen implements Screen, DisplayLines { + + private final static Logger log = LoggerFactory.getLogger(DefaultScreen.class); + private boolean showCursor; + private Position cursorPosition = new Position(0, 0); + private int rows = 0; + private int columns = 0; + + public DefaultScreen() { + this(0, 0); + } + + public DefaultScreen(int rows, int columns) { + resize(rows, columns); + } + + @Override + public WriterBuilder writerBuilder() { + return new DefaultWriterBuilder(); + } + + @Override + public void setShowCursor(boolean showCursor) { + this.showCursor = showCursor; + } + + @Override + public boolean isShowCursor() { + return showCursor; + } + + @Override + public void setCursorPosition(Position cursorPosition) { + this.cursorPosition = cursorPosition; + } + + @Override + public Position getCursorPosition() { + return cursorPosition; + } + + @Override + public void resize(int rows, int columns) { + Assert.isTrue(rows > -1, "Cannot have negative rows size"); + Assert.isTrue(columns > -1, "Cannot have negative columns size"); + this.rows = rows; + this.columns = columns; + reset(); + log.trace("Screen reset rows={} cols={}", this.rows, this.columns); + } + + @Override + public ScreenItem[][] getItems() { + return getScreenItems(); + } + + @Override + public Screen clip(int x, int y, int width, int height) { + return null; + } + + private static char[] BOX_CHARS = new char[] { ' ', '╴', '╵', '┌', '╶', '─', '┐', '┬', '╷', '└', '│', '├', '┘', '┴', + '┤', '┼' }; + + @Override + public List getScreenLines() { + List newLines = new ArrayList<>(); + ScreenItem[][] items = getScreenItems(); + for (int i = 0; i < items.length; i++) { + AttributedStringBuilder builder = new AttributedStringBuilder(); + for (int j = 0; j < items[i].length; j++) { + DefaultScreenItem item = (DefaultScreenItem) items[i][j]; + if (item != null) { + AttributedStyle s = new AttributedStyle(AttributedStyle.DEFAULT); + if (item.background > -1) { + s = s.backgroundRgb(item.getBackground()); + } + if (item.foreground > -1) { + s = s.foregroundRgb(item.getForeground()); + } + if (item.style > -1) { + if ((item.style & ScreenItem.STYLE_BOLD) == ScreenItem.STYLE_BOLD) { + s = s.bold(); + } + if ((item.style & ScreenItem.STYLE_FAINT) == ScreenItem.STYLE_FAINT) { + s = s.faint(); + } + if ((item.style & ScreenItem.STYLE_ITALIC) == ScreenItem.STYLE_ITALIC) { + s = s.italic(); + } + if ((item.style & ScreenItem.STYLE_UNDERLINE) == ScreenItem.STYLE_UNDERLINE) { + s = s.underline(); + } + if ((item.style & ScreenItem.STYLE_BLINK) == ScreenItem.STYLE_BLINK) { + s = s.blink(); + } + if ((item.style & ScreenItem.STYLE_INVERSE) == ScreenItem.STYLE_INVERSE) { + s = s.inverse(); + } + if ((item.style & ScreenItem.STYLE_CONCEAL) == ScreenItem.STYLE_CONCEAL) { + s = s.conceal(); + } + if ((item.style & ScreenItem.STYLE_CROSSEDOUT) == ScreenItem.STYLE_CROSSEDOUT) { + s = s.crossedOut(); + } + } + if (item.getContent() != null){ + builder.append(item.getContent(), s); + } + else if (item.getBorder() > 0) { + builder.append(Character.toString(BOX_CHARS[item.getBorder()]), s); + } + else { + builder.append(Character.toString(' '), s); + } + } + else { + builder.append(' '); + } + } + newLines.add(builder.toAttributedString()); + } + return newLines; + } + + /** + * Default private implementation of a {@link ScreenItem}. + */ + private static class DefaultScreenItem implements ScreenItem { + + CharSequence content; + int foreground = -1; + int background = -1; + int style = -1; + int border; + + @Override + public CharSequence getContent() { + return content; + } + + @Override + public int getBorder() { + return border; + } + + @Override + public int getBackground() { + return background; + } + + @Override + public int getForeground() { + return foreground; + } + + @Override + public int getStyle() { + return style; + } + + } + + /** + * Default private implementation of a {@link WriterBuilder}. + */ + private class DefaultWriterBuilder implements WriterBuilder { + + int layer; + int color = -1; + int style = -1; + + @Override + public Writer build() { + return new DefaultWriter(layer, color, style); + } + + @Override + public WriterBuilder layer(int index) { + this.layer = index; + return this; + } + + @Override + public WriterBuilder color(int color) { + this.color = color; + return this; + } + + @Override + public WriterBuilder style(int style) { + this.style = style; + return this; + } + } + + private void reset() { + layers.clear(); + // DefaultScreenItem[][] layer0 = layerItems.computeIfAbsent(0, l -> { + // return new DefaultScreenItem[rows][columns]; + // }); + // this.items = new DefaultScreenItem[rows][columns]; + for (int i = 0; i < rows; i++) { + // this.items[i] = new DefaultScreenItem[columns]; + // layer0[i] = new DefaultScreenItem[columns]; + + for (int j = 0; j < columns; j++) { + // this.items[i][j] = new DefaultScreenItem(); + // layer0[i][j] = new DefaultScreenItem(); + } + } + } + + private class Layer { + DefaultScreenItem[][] items = new DefaultScreenItem[rows][columns]; + + DefaultScreenItem getScreenItem(int x, int y) { + // if (items[y] == null) { + // items[y] = new DefaultScreenItem[columns]; + // } + if (items[y][x] == null) { + items[y][x] = new DefaultScreenItem(); + } + return items[y][x]; + } + } + + private Map layers = new TreeMap<>(); + + private Layer getLayer(int index) { + Layer layer = layers.computeIfAbsent(index, l -> { + return new Layer(); + }); + return layer; + } + + // @Override + public ScreenItem[][] getScreenItems() { + DefaultScreenItem[][] projection = new DefaultScreenItem[rows][columns]; + layers.entrySet().stream().forEach(entry -> { + Layer layer = entry.getValue(); + DefaultScreenItem[][] layerItems = layer.items; + for (int i = 0; i < rows; i++) { + // if (projection[i] == null) { + // projection[i] = new DefaultScreenItem[columns]; + // } + + for (int j = 0; j < columns; j++) { + if (layerItems[i][j] != null) { + projection[i][j] = layerItems[i][j]; + } + } + } + + }); + return projection; + } + + /** + * Default private implementation of a {@link Writer}. + */ + private class DefaultWriter implements Writer { + + int index; + int color = -1; + int style = -1; + + DefaultWriter(int index, int color, int style) { + this.index = index; + this.color = color; + this.style = style; + } + + @Override + public void text(String text, int x, int y) { + Layer layer = getLayer(index); + for (int i = 0; i < text.length() && i < columns; i++) { + char c = text.charAt(i); + DefaultScreenItem item = layer.getScreenItem(x + i, y); + item.content = Character.toString(c); + if (color > -1) { + item.foreground = color; + } + if (style > -1) { + item.style = style; + } + } + } + + @Override + public void border(int x, int y, int width, int height) { + log.trace("PrintBorder rows={}, columns={}, x={}, y={}, width={}, height={}", rows, columns, x, y, width, + height); + printBorderHorizontal(x, y, width); + printBorderHorizontal(x, y + height - 1, width); + printBorderVertical(x, y, height); + printBorderVertical(x + width - 1, y, height); + } + + @Override + public void background(Rectangle rect, int color) { + log.trace("Background {} {}", color, rect); + Layer layer = getLayer(index); + for (int i = rect.y(); i < rect.y() + rect.height(); i++) { + for (int j = rect.x(); j < rect.x() + rect.width(); j++) { + DefaultScreenItem item = layer.getScreenItem(j, i); + item.background = color; + } + } + } + + @Override + public void text(String text, Rectangle rect, HorizontalAlign hAlign, VerticalAlign vAlign) { + int x = rect.x(); + if (hAlign == HorizontalAlign.CENTER) { + x = (x + rect.width()) / 2; + x = x - text.length() / 2; + } + else if (hAlign == HorizontalAlign.RIGHT) { + x = x + rect.width() - text.length(); + } + int y = rect.y(); + if (vAlign == VerticalAlign.CENTER) { + y = (y + rect.height()) / 2; + } + else if (vAlign == VerticalAlign.BOTTOM) { + y = y + rect.height() - 1; + } + text(text, x, y); + } + + private void printBorderHorizontal(int x, int y, int width) { + Layer layer = getLayer(index); + for (int i = x; i < x + width; i++) { + if (i < 0 || i >= columns) { + continue; + } + if (y >= rows) { + continue; + } + DefaultScreenItem item = layer.getScreenItem(i, y); + if (i > x) { + item.border |= ScreenItem.BORDER_RIGHT; + } + if (i < x + width - 1) { + item.border |= ScreenItem.BORDER_LEFT; + } + } + } + + private void printBorderVertical(int x, int y, int height) { + Layer layer = getLayer(index); + for (int i = y; i < y + height; i++) { + if (i < 0 || i >= rows) { + continue; + } + if (x >= columns) { + continue; + } + DefaultScreenItem item = layer.getScreenItem(x, i); + if (i > y) { + item.border |= ScreenItem.BORDER_BOTTOM; + } + if (i < y + height - 1) { + item.border |= ScreenItem.BORDER_TOP; + } + } + } + + + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DisplayLines.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DisplayLines.java new file mode 100644 index 000000000..8a6701e9e --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/DisplayLines.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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.screen; + +import java.util.List; + +import org.jline.utils.AttributedString; +import org.jline.utils.Display; + +/** + * Interface for an implementation which is able to return list of + * {@link AttributedString} usually used together with {@link Display}. + * + * @author Janne Valkealahti + */ +public interface DisplayLines { + + /** + * Gets a list of screen lines. + * + * @return list of screen lines + */ + List getScreenLines(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Screen.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Screen.java new file mode 100644 index 000000000..ebf7eed96 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/Screen.java @@ -0,0 +1,164 @@ +/* + * Copyright 2023 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.screen; + +import org.springframework.lang.Nullable; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.Position; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.geom.VerticalAlign; + +/** + * {@code Screen} is representing a virtual area which is sitting between a user + * and lower level {@code jline} terminal providing convenient methods working + * with visible content. + * + * @author Janne Valkealahti + */ +public interface Screen { + + /** + * Sets if cursor should be visible. + * + * @param show true if cursor should be visible + */ + void setShowCursor(boolean show); + + /** + * Gets if cursor is visible. + * + * @return true if cursor is visible + */ + boolean isShowCursor(); + + /** + * Sets a cursor position. + * + * @param position new cursor position + */ + void setCursorPosition(Position position); + + /** + * Gets a cursor position. + * + * @return cursor position + */ + Position getCursorPosition(); + + /** + * Gets a new instance of a {@link WriterBuilder}. + * + * @return a new writer builder + */ + WriterBuilder writerBuilder(); + + /** + * Resize a screen. + * + * @param rows the new row count + * @param columns the new column count + */ + void resize(int rows, int columns); + + /** + * Gets a screen items. + * + * @return a screen items + */ + ScreenItem[][] getItems(); + + /** + * Clip a screen with a given bounds. + * + * @param x the x coordinate + * @param y the y coordinate + * @param width the width + * @param height the height + * @return new clipped screen + */ + Screen clip(int x, int y, int width, int height); + + /** + * Interface to write into a {@link Screen}. Contains convenient methods user is + * most likely to need to operate on a {@link Screen}. + */ + interface Writer { + + /** + * Write a text horizontally starting from a position defined by {@code x} and + * {@code y} within a bounds of a {@link Screen}. + * + * @param text the text to write + * @param x the x position + * @param y the y position + */ + void text(String text, int x, int y); + + /** + * Write a border with a given rectangle coordinates. + * + * @param x the x position + * @param y the y position + * @param width the rectangle width + * @param height the rectangle height + */ + void border(int x, int y, int width, int height); + + /** + * Fill background with a given color. + * + * @param rect the rectange to fill + * @param color the color to use + */ + void background(Rectangle rect, int color); + + /** + * Write aligned text within a bounds. + * + * @param text the text to write + * @param rect the rectangle bounds + * @param hAlign the horizontal aligment + * @param vAlign the vertical aligment + */ + void text(String text, Rectangle rect, @Nullable HorizontalAlign hAlign, @Nullable VerticalAlign vAlign); + } + + /** + * Builder interface for a {@link Writer}. Allows to defined settings a builder + * will operare on. + */ + interface WriterBuilder { + + /** + * Define a {@code z-index} this {@link Writer} operates on. + * {@code WriterBuilder} defaults on a layer index {@code 0}. + * + * @param index the z-index + * @return a writer builder for chaining + */ + WriterBuilder layer(int index); + + WriterBuilder color(int color); + WriterBuilder style(int style); + + /** + * Build a {@link Writer}. + * + * @return a build writer + */ + Writer build(); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/ScreenItem.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/ScreenItem.java new file mode 100644 index 000000000..1ebd1542d --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/screen/ScreenItem.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 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.screen; + +/** + * + * + * @author Janne Valkealahti + */ +public interface ScreenItem { + + static final int STYLE_BOLD = 1; + static final int STYLE_FAINT = STYLE_BOLD << 1; + static final int STYLE_ITALIC = STYLE_BOLD << 2; + static final int STYLE_UNDERLINE = STYLE_BOLD << 3; + static final int STYLE_BLINK = STYLE_BOLD << 4; + static final int STYLE_INVERSE = STYLE_BOLD << 5; + static final int STYLE_CONCEAL = STYLE_BOLD << 6; + static final int STYLE_CROSSEDOUT = STYLE_BOLD << 7; + + static final int BORDER_LEFT = 1; + static final int BORDER_TOP = BORDER_LEFT << 1; + static final int BORDER_RIGHT = BORDER_LEFT << 2; + static final int BORDER_BOTTOM = BORDER_LEFT << 3; + + CharSequence getContent(); + + int getBorder(); + + int getBackground(); + + int getForeground(); + + int getStyle(); + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssert.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssert.java new file mode 100644 index 000000000..7a73d3814 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssert.java @@ -0,0 +1,326 @@ +/* + * Copyright 2023 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; + +import java.util.Arrays; +import java.util.List; + +import org.assertj.core.api.AbstractAssert; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.view.geom.Position; +import org.springframework.shell.component.view.screen.DisplayLines; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.ScreenItem; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Asserts for {@link Screen}. This is a work-in-progress implementation and + * relevant parts are copied into spring-shell-test. + * + * @author Janne Valkealahti + */ +public class ScreenAssert extends AbstractAssert { + + public ScreenAssert(Screen actual) { + super(actual, ScreenAssert.class); + } + + /** + * Verifies that the actual {@link Screen} has a cursor visible. + * + * @return this assertion object + */ + public ScreenAssert hasCursorVisible() { + isNotNull(); + if (!actual.isShowCursor()) { + failWithMessage("Expecting a Screen to have a visible cursor", actual); + } + return this; + } + + /** + * Verifies that the actual {@link Screen} has a cursor in a given position. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @return this assertion object + */ + public ScreenAssert hasCursorInPosition(int x, int y) { + isNotNull(); + Position cursorPosition = actual.getCursorPosition(); + if (cursorPosition.x() != x || cursorPosition.y() != y) { + failWithMessage("Expecting a Screen to have position <%s,%s> but was <%s,%s>", x, y, cursorPosition.x(), + cursorPosition.y()); + } + return this; + } + + /** + * Verifies that the actual {@link Screen} has a foreground color in a position. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @param color the color + * @return this assertion object + */ + public ScreenAssert hasForegroundColor(int x, int y, int color) { + isNotNull(); + ScreenItem[][] items = actual.getItems(); + ScreenItem i = items[y][x]; + int expectedColor = i.getForeground(); + if (expectedColor != color) { + failWithMessage("Expecting a Screen to have foreground color <%s> position <%s,%s> but was <%s>", color, x, y, + expectedColor); + } + return this; + } + + /** + * Verifies that the actual {@link Screen} has a foreground style in a position. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @param color the style + * @return this assertion object + */ + public ScreenAssert hasStyle(int x, int y, int style) { + isNotNull(); + ScreenItem[][] items = actual.getItems(); + ScreenItem i = items[y][x]; + int expectedStyle = i.getStyle(); + if (expectedStyle != style) { + failWithMessage("Expecting a Screen to have style <%s> position <%s,%s> but was <%s>", style, x, y, + expectedStyle); + } + return this; + } + + /** + * Verifies that the actual {@link Screen} has a background color in a position. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @param color the color + * @return this assertion object + */ + public ScreenAssert hasBackgroundColor(int x, int y, int color) { + isNotNull(); + ScreenItem[][] items = actual.getItems(); + ScreenItem i = items[y][x]; + int expectedColor = i.getBackground(); + if (expectedColor != color) { + failWithMessage("Expecting a Screen to have background color <%s> position <%s,%s> but was <%s>", color, x, y, + expectedColor); + } + return this; + } + + /** + * Verifies that a given bounded box is legal for a screen and that characters + * along border look like border characters. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @param width a width in a screen + * @param height a height in a screen + * @return this assertion object + */ + public ScreenAssert hasBorder(int x, int y, int width, int height) { + return hasBorderType(x, y, width, height, true); + } + + /** + * Verifies that a given bounded box is legal for a screen and that characters + * along border doesn't look like border characters. + * + * @param x a x position in a screen + * @param y a y position in a screen + * @param width a width in a screen + * @param height a height in a screen + * @return this assertion object + */ + public ScreenAssert hasNoBorder(int x, int y, int width, int height) { + return hasBorderType(x, y, width, height, false); + } + + /** + * Verifies that a given text can be found from a screen coordinates following + * horizontal width. + * + * @param text a text to verify + * @param x a x position of a text + * @param y a y position of a text + * @param width a width of a text to check + * @return this assertion object + */ + public ScreenAssert hasHorizontalText(String text, int x, int y, int width) { + isNotNull(); + ScreenItem[][] content = actual.getItems(); + checkBounds(content, x, y, width, 1); + ScreenItem[] items = getHorizontalBorder(content, y, x, width); + StringBuilder buf = new StringBuilder(); + for (int i = 0; i < items.length; i++) { + if (items[i].getContent() != null) { + buf.append(items[i].getContent()); + } + } + String actualText = buf.toString(); + assertThat(actualText).isEqualTo(text); + return this; + } + + /** + * Verifies that a given text can not be found from a screen coordinates following + * horizontal width. + * + * @param text a text to verify + * @param x a x position of a text + * @param y a y position of a text + * @param width a width of a text to check + * @return this assertion object + */ + public ScreenAssert hasNoHorizontalText(String text, int x, int y, int width) { + isNotNull(); + ScreenItem[][] content = actual.getItems(); + checkBounds(content, x, y, width, 1); + ScreenItem[] items = getHorizontalBorder(content, x, y, width); + StringBuilder buf = new StringBuilder(); + for (int i = 0; i < items.length; i++) { + if (items[i] != null) { + if (items[i].getContent() != null) { + buf.append(items[i].getContent()); + } + } + } + String actualText = buf.toString(); + assertThat(actualText).isNotEqualTo(text); + return this; + } + + private String screenError(int x, int y, int width, int height) { + StringBuffer buf = new StringBuffer(); + if (actual instanceof DisplayLines dl1) { + List screenLines = dl1.getScreenLines(); + buf.append(String.format("%nExpecting screen:%n")); + for (AttributedString line : screenLines) { + buf.append(String.format("%n %s", AttributedString.stripAnsi(line.toString()))); + } + Screen clip = actual.clip(x, y, width, height); + if (clip instanceof DisplayLines dl2) { + List screenLines2 = dl2.getScreenLines(); + buf.append(String.format("%nhave border in bounded box x=%s y=%s width=%s height=%s, was:%n", x, y, width, height)); + for (AttributedString line : screenLines2) { + buf.append(String.format("%n %s", AttributedString.stripAnsi(line.toString()))); + } + } + } + return buf.toString(); + } + + private ScreenAssert hasBorderType(int x, int y, int width, int height, boolean border) { + isNotNull(); + ScreenItem[][] content = actual.getItems(); + checkBounds(content, x, y, width, height); + ScreenItem[][] borders = getBorders(content, x, y, width, height); + ScreenItem[] topBorder = borders[0]; + ScreenItem[] rightBorder = borders[1]; + ScreenItem[] bottomBorder = borders[2]; + ScreenItem[] leftBorder = borders[3]; + if (topBorder.length != width) { + failWithMessage("Top Border size doesn't match"); + } + String failMessage = screenError(x, y, width, height); + assertThat(topBorder).withFailMessage(failMessage).allSatisfy(b -> { + if (border) { + assertThat(b).isNotNull(); + assertThat(b.getBorder()).isGreaterThan(0); + } + else { + if (b != null) { + assertThat(b.getBorder()).isEqualTo(0); + } + } + }); + assertThat(rightBorder).withFailMessage(failMessage).allSatisfy(b -> { + if (border) { + assertThat(b).isNotNull(); + assertThat(b.getBorder()).isGreaterThan(0); + } + else { + if (b != null) { + assertThat(b.getBorder()).isEqualTo(0); + } + } + }); + assertThat(bottomBorder).withFailMessage(failMessage).allSatisfy(b -> { + if (border) { + assertThat(b).isNotNull(); + assertThat(b.getBorder()).isGreaterThan(0); + } + else { + if (b != null) { + assertThat(b.getBorder()).isEqualTo(0); + } + } + }); + assertThat(leftBorder).withFailMessage(failMessage).allSatisfy(b -> { + if (border) { + assertThat(b).isNotNull(); + // assertThat(b.getType()).isEqualTo(Screen.Type.BORDER); + assertThat(b.getBorder()).isGreaterThan(0); + } + else { + if (b != null) { + // assertThat(b.getType()).isNotEqualTo(Screen.Type.BORDER); + assertThat(b.getBorder()).isEqualTo(0); + } + } + }); + return this; + } + + private ScreenItem[][] getBorders(ScreenItem[][] content, int x, int y, int width, int height) { + ScreenItem[] topBorder = getHorizontalBorder(content, y, x, width); + ScreenItem[] rightBorder = getVerticalBorder(content, x + width - 1, y, height); + ScreenItem[] bottomBorder = getHorizontalBorder(content, y + height - 1, x, width); + ScreenItem[] leftBorder = getVerticalBorder(content, x, y, height); + return new ScreenItem[][] { topBorder, rightBorder, bottomBorder, leftBorder }; + } + + private ScreenItem[] getHorizontalBorder(ScreenItem[][] content, int row, int start, int width) { + return Arrays.copyOfRange(content[row], start, start + width); + } + + private ScreenItem[] getVerticalBorder(ScreenItem[][] content, int column, int start, int height) { + ScreenItem[] array = new ScreenItem[height]; + for (int i = 0; i < array.length; i++) { + array[i] = content[start][column]; + } + return array; + } + + private void checkBounds(ScreenItem[][] content, int x, int y, int width, int height) { + if (x < 0 || y < 0 || width < 1 || height < 1) { + failWithMessage("Can't assert with negative bounded rectangle, was x=%s y=%s width=%s height=%s", x, y, + width, height); + } + if (x >= content[0].length) { + failWithMessage("Can't assert position x %s as width is %s", x, content[0].length); + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssertTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssertTests.java new file mode 100644 index 000000000..a17285015 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/ScreenAssertTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2023 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; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Color; +import org.springframework.shell.component.view.screen.DefaultScreen; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.ScreenItem; +import org.springframework.shell.component.view.screen.Screen.Writer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ScreenAssertTests { + + @Test + void testCursorPosition() { + Screen screen = new DefaultScreen(5, 5); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasCursorVisible()) + .withMessageContaining("Expecting a Screen to have a visible cursor"); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasCursorInPosition(1, 1)) + .withMessageContaining("Expecting a Screen to have position <1,1> but was <0,0>"); + } + + @Test + void hasForegroundColorShouldPass() { + Screen screen = new DefaultScreen(5, 5); + screen.writerBuilder().color(Color.RED).build().text("test", 0, 0); + assertThat(forScreen(screen)).hasForegroundColor(0, 0, Color.RED); + } + + @Test + void hasForegroundColorShouldFail() { + Screen screen = new DefaultScreen(5, 5); + screen.writerBuilder().color(Color.RED).build().text("test", 0, 0); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasForegroundColor(0, 0, Color.BLUE)) + .withMessageContaining("Expecting a Screen to have foreground color <255> position <0,0> but was <16711680>"); + } + + @Test + void hasStyleShouldPass() { + Screen screen = new DefaultScreen(5, 5); + screen.writerBuilder().style(ScreenItem.STYLE_BOLD).build().text("test", 0, 0); + assertThat(forScreen(screen)).hasStyle(0, 0, ScreenItem.STYLE_BOLD); + } + + @Test + void hasStyleShouldFail() { + Screen screen = new DefaultScreen(5, 5); + screen.writerBuilder().build().text("test", 0, 0); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasStyle(0, 0, ScreenItem.STYLE_BOLD)) + .withMessageContaining("Expecting a Screen to have style <1> position <0,0> but was <-1>"); + } + + @Test + void hasBackgroundColorShouldPass() { + Screen screen = new DefaultScreen(5, 5); + Writer writer = screen.writerBuilder().build(); + writer.background(new Rectangle(0, 0, 5, 5), Color.BLUE); + writer.text("test", 0, 0); + assertThat(forScreen(screen)).hasBackgroundColor(0, 0, Color.BLUE); + } + + @Test + void hasBackgroundColorShouldFail() { + Screen screen = new DefaultScreen(5, 5); + Writer writer = screen.writerBuilder().build(); + writer.text("test", 0, 0); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBackgroundColor(0, 0, Color.BLUE)) + .withMessageContaining("Expecting a Screen to have background color <255> position <0,0> but was <-1>"); + } + + @Test + void shouldThrowWithInvalidBounds() { + Screen screen = new DefaultScreen(5, 5); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBorder(0, 0, 0, 0)); + } + + @Test + void shouldThrowWithInvalidBorder() { + Screen screen = new DefaultScreen(5, 5); + screen.writerBuilder().build().border(0, 0, 5, 5); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBorder(0, 0, 5, 4)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBorder(0, 0, 5, 4)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBorder(0, 0, 4, 4)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(forScreen(screen)).hasBorder(1, 1, 3, 3)); + } + + @Test + void shouldNotThrowWithValidBorder() { + Screen screen = new DefaultScreen(5, 10); + screen.writerBuilder().build().border(0, 0, 10, 5); + assertThat(forScreen(screen)).hasBorder(0, 0, 10, 5); + + screen = new DefaultScreen(10, 5); + screen.writerBuilder().build().border(0, 0, 5, 10); + assertThat(forScreen(screen)).hasBorder(0, 0, 5, 10); + + screen = new DefaultScreen(10, 5); + screen.writerBuilder().build().border(1, 1, 3, 8); + assertThat(forScreen(screen)).hasBorder(1, 1, 3, 8); + } + + @Test + void shouldNotThrowWithValidNonBorder() { + Screen screen = new DefaultScreen(5, 10); + screen.writerBuilder().build().border(0, 0, 10, 5); + assertThat(forScreen(screen)).hasNoBorder(1, 1, 8, 3); + } + + @Test + void hasHorizontalText() { + Screen screen = new DefaultScreen(5, 10); + screen.writerBuilder().build().text("test", 0, 0); + assertThat(forScreen(screen)).hasHorizontalText("test", 0, 0, 4); + } + + @Test + void hasNoHorizontalText() { + Screen screen = new DefaultScreen(5, 10); + screen.writerBuilder().build().text("xxxx", 0, 0); + assertThat(forScreen(screen)).hasNoHorizontalText("test", 0, 0, 4); + } + + // @Test + // void xxx() { + // Screen screen = new Screen(5, 5); + // screen.printBorder(0, 0, 5, 5); + // assertThat(forScreen(screen)).hasBorder(0, 0, 5, 4); + // } + + private AssertProvider forScreen(Screen screen) { + return () -> new ScreenAssert(screen); + } +} 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 new file mode 100644 index 000000000..8a44c6184 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023 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 org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.shell.component.view.ScreenAssert; +import org.springframework.shell.component.view.event.DefaultEventLoop; +import org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.KeyHandler.KeyHandlerResult; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.shell.component.view.screen.DefaultScreen; +import org.springframework.shell.component.view.screen.Screen; + +public class AbstractViewTests { + + protected Screen screen24x80; + protected Screen screen7x10; + protected Screen screen10x10; + protected Screen screen0x0; + protected DefaultEventLoop eventLoop; + + @BeforeEach + void setup() { + screen24x80 = new DefaultScreen(24, 80); + screen7x10 = new DefaultScreen(7, 10); + screen0x0 = new DefaultScreen(); + screen10x10 = new DefaultScreen(10, 10); + eventLoop = new DefaultEventLoop(); + } + + @AfterEach + void cleanup() { + if (eventLoop != null) { + eventLoop.destroy(); + } + eventLoop = null; + } + + protected void configure(View view) { + if (eventLoop != null) { + if (view instanceof AbstractView v) { + v.setEventLoop(eventLoop); + } + eventLoop.onDestroy(eventLoop.mouseEvents() + .doOnNext(m -> { + view.getMouseHandler().handle(MouseHandler.argsOf(m)); + }) + .subscribe()); + eventLoop.onDestroy(eventLoop.keyEvents() + .doOnNext(m -> { + view.getKeyHandler().handle(KeyHandler.argsOf(m)); + }) + .subscribe()); + } + } + + protected void dispatchEvent(View view, KeyEvent event) { + KeyHandler keyHandler = view.getKeyHandler(); + if (keyHandler != null) { + keyHandler.handle(new KeyHandler.KeyHandlerArgs(event)); + } + } + + protected AssertProvider forScreen(Screen screen) { + return () -> new ScreenAssert(screen); + } + + protected KeyHandlerResult handleKey(View view, Integer key) { + return handleKeyEvent(view, KeyEvent.of(key)); + } + + protected KeyHandlerResult handleKeyEvent(View view, KeyEvent key) { + return view.getKeyHandler().handle(KeyHandler.argsOf(key)); + } + + protected MouseEvent mouseClick(int x, int y) { + return MouseEvent.of(x, y, MouseEvent.Type.Released | MouseEvent.Button.Button1); + } + + protected MouseHandlerResult handleMouseClick(View view, int x, int y) { + MouseEvent click = mouseClick(0, 2); + return handleMouseClick(view, click); + } + + protected MouseHandlerResult handleMouseClick(View view, MouseEvent click) { + return view.getMouseHandler().handle(MouseHandler.argsOf(click)); + } + + protected MouseHandlerResult handleMouseWheelDown(View view, int x, int y) { + MouseEvent wheel = mouseWheelDown(x, y); + return view.getMouseHandler().handle(MouseHandler.argsOf(wheel)); + } + + protected MouseHandlerResult handleMouseWheelUp(View view, int x, int y) { + MouseEvent wheel = mouseWheelUp(x, y); + return view.getMouseHandler().handle(MouseHandler.argsOf(wheel)); + } + + protected MouseEvent mouseWheelUp(int x, int y) { + return MouseEvent.of(x, y, MouseEvent.Type.Wheel | MouseEvent.Button.WheelUp); + } + + protected MouseEvent mouseWheelDown(int x, int y) { + return MouseEvent.of(x, y, MouseEvent.Type.Wheel | MouseEvent.Button.WheelDown); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BoxViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BoxViewTests.java new file mode 100644 index 000000000..199f35d7c --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BoxViewTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; + +import static org.assertj.core.api.Assertions.assertThat; + +class BoxViewTests extends AbstractViewTests { + + @Test + void hasBorder() { + BoxView view = new BoxView(); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + + BoxView view2 = new BoxView(); + view2.setShowBorder(true); + view2.setRect(0, 0, 10, 7); + view2.draw(screen7x10); + assertThat(forScreen(screen7x10)).hasBorder(0, 0, 10, 7); + } + + @Test + void hasNoBorder() { + BoxView view = new BoxView(); + view.setShowBorder(false); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasNoBorder(0, 0, 80, 24); + } + + @Test + void hasTitle() { + BoxView view = new BoxView(); + view.setShowBorder(true); + view.setTitle("title"); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasHorizontalText("title", 1, 0, 5); + } + + @Test + void hasNoTitleWhenNoBorder() { + BoxView view = new BoxView(); + view.setShowBorder(false); + view.setTitle("title"); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasNoHorizontalText("title", 0, 1, 5); + } + + @Test + void mouseClickInBounds() { + BoxView view = new BoxView(); + configure(view); + view.setRect(0, 0, 80, 24); + MouseEvent event = mouseClick(0, 0); + MouseHandlerResult result = view.getMouseHandler().handle(MouseHandler.argsOf(event)); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(event); + // assertThat(r.consumed()).isTrue(); + // assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + } + + @Test + void mouseClickOutOfBounds() { + BoxView view = new BoxView(); + view.setRect(0, 0, 80, 24); + MouseEvent event = mouseClick(100, 100); + MouseHandlerResult result = view.getMouseHandler().handle(MouseHandler.argsOf(event)); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(event); + assertThat(r.consumed()).isFalse(); + assertThat(r.focus()).isNull(); + assertThat(r.capture()).isEqualTo(view); + }); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java new file mode 100644 index 000000000..1c707f93f --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.screen.DefaultScreen; + +import static org.assertj.core.api.Assertions.assertThat; + +class GridViewTests extends AbstractViewTests { + + @Test + void hasBordersWith1x1() { + BoxView box1 = new BoxView(); + + GridView grid = new GridView(); + grid.setShowBorders(true); + grid.setRowSize(0); + grid.setColumnSize(0); + grid.setShowBorder(false); + grid.addItem(box1, 0, 0, 1, 1, 0, 0); + + grid.setRect(0, 0, 80, 24); + grid.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + + @Test + void hasBordersWith1x2() { + BoxView box1 = new BoxView(); + BoxView box2 = new BoxView(); + + GridView grid = new GridView(); + grid.setShowBorders(true); + grid.setRowSize(0); + grid.setColumnSize(0, 0); + grid.setShowBorder(false); + grid.addItem(box1, 0, 0, 1, 1, 0, 0); + grid.addItem(box2, 0, 1, 1, 1, 0, 0); + + grid.setRect(0, 0, 80, 24); + grid.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 39, 24); + assertThat(forScreen(screen24x80)).hasBorder(39, 0, 41, 24); + } + + @Test + void hasBordersWith2x1() { + BoxView box1 = new BoxView(); + BoxView box2 = new BoxView(); + + GridView grid = new GridView(); + grid.setShowBorders(true); + grid.setRowSize(0, 0); + grid.setColumnSize(0); + grid.setShowBorder(false); + grid.addItem(box1, 0, 0, 1, 1, 0, 0); + grid.addItem(box2, 1, 0, 1, 1, 0, 0); + + grid.setRect(0, 0, 80, 24); + grid.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 12); + assertThat(forScreen(screen24x80)).hasBorder(0, 11, 80, 13); + } + + @Test + void hasBordersWith2x2() { + BoxView box1 = new BoxView(); + BoxView box2 = new BoxView(); + BoxView box3 = new BoxView(); + BoxView box4 = new BoxView(); + + GridView grid = new GridView(); + grid.setShowBorders(true); + grid.setRowSize(0, 0); + grid.setColumnSize(0, 0); + grid.setShowBorder(false); + grid.addItem(box1, 0, 0, 1, 1, 0, 0); + grid.addItem(box2, 0, 1, 1, 1, 0, 0); + grid.addItem(box3, 1, 0, 1, 1, 0, 0); + grid.addItem(box4, 1, 1, 1, 1, 0, 0); + + grid.setRect(0, 0, 80, 24); + grid.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 39, 12); + assertThat(forScreen(screen24x80)).hasBorder(39, 0, 41, 12); + assertThat(forScreen(screen24x80)).hasBorder(0, 11, 80, 13); + assertThat(forScreen(screen24x80)).hasBorder(39, 11, 41, 13); + } + + @Test + void hasBordersWithHidden() { + screen24x80 = new DefaultScreen(20, 10); + + BoxView menu = new BoxView(); + BoxView main = new BoxView(); + BoxView sideBar = new BoxView(); + BoxView header = new BoxView(); + BoxView footer = new BoxView(); + + GridView grid = new GridView(); + grid.setRowSize(3, 0, 3); + grid.setColumnSize(30, 0, 30); + grid.setShowBorders(true); + + grid.addItem(header, 0, 0, 1, 3, 0, 0); + grid.addItem(footer, 2, 0, 1, 3, 0, 0); + + grid.addItem(menu, 0, 0, 0, 0, 0, 0); + grid.addItem(main, 1, 0, 1, 3, 0, 0); + grid.addItem(sideBar, 0, 0, 0, 0, 0, 0); + + grid.addItem(menu, 1, 0, 1, 1, 0, 100); + grid.addItem(main, 1, 1, 1, 1, 0, 100); + grid.addItem(sideBar, 1, 2, 1, 1, 0, 100); + + grid.setRect(0, 0, 10, 20); + grid.draw(screen24x80); + + // overflows, don't have full border anywhere + // assertThat(forScreen(screen)).hasBorder(0, 0, 80, 24); + } + + @Test + void gridBoxHasTitle() { + BoxView box1 = new BoxView(); + + GridView grid = new GridView(); + grid.setShowBorder(true); + grid.setTitle("title"); + grid.setShowBorders(true); + grid.setRowSize(0); + grid.setColumnSize(0); + grid.addItem(box1, 0, 0, 1, 1, 0, 0); + + grid.setRect(0, 0, 80, 24); + grid.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + assertThat(forScreen(screen24x80)).hasBorder(1, 1, 78, 22); + assertThat(forScreen(screen24x80)).hasHorizontalText("title", 1, 0, 5); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/InputViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/InputViewTests.java new file mode 100644 index 000000000..058d14542 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/InputViewTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.KeyEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +class InputViewTests extends AbstractViewTests { + + @Test + void shouldShowInput() { + InputView view = new InputView(); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + + dispatchEvent(view, KeyEvent.of('1')); + view.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasHorizontalText("1", 1, 1, 1); + assertThat(forScreen(screen24x80)).hasCursorInPosition(2, 1); + } + + @Test + void shouldShowUnicode() { + InputView view = new InputView(); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + + dispatchEvent(view, KeyEvent.of('★')); + view.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasHorizontalText("★", 1, 1, 1); + assertThat(forScreen(screen24x80)).hasCursorInPosition(2, 1); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ListViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ListViewTests.java new file mode 100644 index 000000000..51f8318f3 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/ListViewTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.control.cell.ListCell; +import org.springframework.shell.component.view.event.KeyEvent; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyHandler; +import org.springframework.shell.component.view.event.KeyHandler.KeyHandlerResult; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class ListViewTests extends AbstractViewTests { + + @Test + void hasBorder() { + ListView view = new ListView<>(); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + + @Test + void arrowKeysMoveSelection() { + ListView view = new ListView<>(); + configure(view); + view.setRect(0, 0, 80, 24); + view.setItems(Arrays.asList("item1", "item2")); + assertThat(ReflectionTestUtils.getField(view, "selected")).isEqualTo(-1); + + KeyEvent eventDown = KeyEvent.of(Key.CursorDown); + KeyEvent eventUp = KeyEvent.of(Key.CursorUp); + KeyHandlerResult result = view.getKeyHandler().handle(KeyHandler.argsOf(eventDown)); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(eventDown); + assertThat(r.consumed()).isTrue(); + // assertThat(r.focus()).isEqualTo(view); + // assertThat(r.capture()).isEqualTo(view); + }); + assertThat(selected(view)).isEqualTo(0); + result = view.getKeyHandler().handle(KeyHandler.argsOf(eventDown)); + assertThat(selected(view)).isEqualTo(1); + result = view.getKeyHandler().handle(KeyHandler.argsOf(eventUp)); + assertThat(selected(view)).isEqualTo(0); + } + + @Test + void mouseWheelMoveSelection() { + ListView view = new ListView<>(); + configure(view); + view.setRect(0, 0, 80, 24); + view.setItems(Arrays.asList("item1", "item2")); + assertThat(selected(view)).isEqualTo(-1); + + MouseEvent eventDown = mouseWheelDown(0, 0); + MouseEvent eventUp = mouseWheelUp(0, 0); + MouseHandlerResult result = view.getMouseHandler().handle(MouseHandler.argsOf(eventDown)); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(eventDown); + // assertThat(r.consumed()).isFalse(); + // assertThat(r.focus()).isNull(); + assertThat(r.capture()).isEqualTo(view); + }); + assertThat(selected(view)).isEqualTo(0); + result = view.getMouseHandler().handle(MouseHandler.argsOf(eventDown)); + assertThat(selected(view)).isEqualTo(1); + result = view.getMouseHandler().handle(MouseHandler.argsOf(eventUp)); + assertThat(selected(view)).isEqualTo(0); + } + + static int selected(ListView view) { + return (int) ReflectionTestUtils.getField(view, "selected"); + } + + @Nested + class Visual { + + @Test + void customCellFactory() { + ListView view = new ListView<>(); + view.setShowBorder(true); + view.setCellFactory(list -> new TestListCell()); + view.setRect(0, 0, 80, 24); + view.setItems(Arrays.asList("item1")); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasHorizontalText("pre-item1-post", 0, 1, 16); + } + + static class TestListCell extends ListCell { + + @Override + public void draw(Screen screen) { + Rectangle rect = getRect(); + Writer writer = screen.writerBuilder().build(); + writer.text(String.format("pre-%s-post", getItem()), rect.x(), rect.y()); + writer.background(rect, getBackgroundColor()); + } + } + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuBarViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuBarViewTests.java new file mode 100644 index 000000000..d51c490b3 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuBarViewTests.java @@ -0,0 +1,215 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.control.MenuBarView.MenuBarItem; +import org.springframework.shell.component.view.control.MenuView.MenuItem; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuBarViewTests extends AbstractViewTests { + + private static final String SELECTED_FIELD = "activeItemIndex"; + private static final String MENUVIEW_FIELD = "currentMenuView"; + + @Nested + class Construction { + + @Test + void constructView() { + MenuBarView view; + + view = MenuBarView.of(MenuBarItem.of("title")); + assertThat(view.getItems()).hasSize(1); + } + + } + + @Nested + class Styling { + + @Test + void hasBorder() { + MenuBarView view = new MenuBarView(new MenuBarView.MenuBarItem[0]); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + + } + + // @Nested + // class Selection { + // MenuBarView view; + + // } + + @Nested + class Menus { + + @Test + void mouseClicksSameOpensAndClosesMenu() { + MenuItem menuItem = new MenuView.MenuItem("sub1"); + MenuBarItem menuBarItem = new MenuBarView.MenuBarItem("menu1", new MenuView.MenuItem[] { menuItem }); + MenuBarView view = new MenuBarView(new MenuBarView.MenuBarItem[] { menuBarItem }); + configure(view); + view.setRect(0, 0, 10, 10); + + MouseEvent click1 = mouseClick(0, 0); + MouseHandlerResult result1 = view.getMouseHandler().handle(MouseHandler.argsOf(click1)); + assertThat(result1).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click1); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + Integer selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + + MenuView menuView1 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView1).isNotNull(); + + MouseEvent click2 = mouseClick(0, 0); + MouseHandlerResult result2 = view.getMouseHandler().handle(MouseHandler.argsOf(click2)); + assertThat(result2).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click2); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + MenuView menuView2 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView2).isNull(); + } + + @Test + void mouseClicksOpensDifferentMenus() { + MenuItem menuItem1 = new MenuView.MenuItem("sub1"); + MenuItem menuItem2 = new MenuView.MenuItem("sub2"); + MenuBarItem menuBarItem1 = new MenuBarView.MenuBarItem("menu1", new MenuView.MenuItem[] { menuItem1 }); + MenuBarItem menuBarItem2 = new MenuBarView.MenuBarItem("menu2", new MenuView.MenuItem[] { menuItem2 }); + MenuBarView view = new MenuBarView(new MenuBarView.MenuBarItem[] { menuBarItem1, menuBarItem2 }); + configure(view); + view.setRect(0, 0, 10, 10); + + MouseEvent click1 = mouseClick(0, 0); + MouseHandlerResult result1 = view.getMouseHandler().handle(MouseHandler.argsOf(click1)); + assertThat(result1).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click1); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + Integer selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + + MenuView menuView1 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView1).isNotNull(); + assertThat(menuView1.getItems().get(0).getTitle()).isEqualTo("sub1"); + + MouseEvent click2 = mouseClick(7, 0); + MouseHandlerResult result2 = view.getMouseHandler().handle(MouseHandler.argsOf(click2)); + assertThat(result2).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click2); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + MenuView menuView2 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView2).isNotNull(); + assertThat(menuView2.getItems().get(0).getTitle()).isEqualTo("sub2"); + } + + @Test + void arrowKeysMoveBetweenDifferentMenus() { + MenuItem menuItem1 = new MenuView.MenuItem("sub1"); + MenuItem menuItem2 = new MenuView.MenuItem("sub2"); + MenuBarItem menuBarItem1 = new MenuBarView.MenuBarItem("menu1", new MenuView.MenuItem[] { menuItem1 }); + MenuBarItem menuBarItem2 = new MenuBarView.MenuBarItem("menu2", new MenuView.MenuItem[] { menuItem2 }); + MenuBarView view = new MenuBarView(new MenuBarView.MenuBarItem[] { menuBarItem1, menuBarItem2 }); + configure(view); + view.setRect(0, 0, 10, 10); + + // Can't yet active menu with a key, so start with a click + MouseEvent click1 = mouseClick(0, 0); + MouseHandlerResult result1 = view.getMouseHandler().handle(MouseHandler.argsOf(click1)); + assertThat(result1).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click1); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + Integer selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + + MenuView menuView1 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView1).isNotNull(); + assertThat(menuView1.getItems().get(0).getTitle()).isEqualTo("sub1"); + + handleKey(view, Key.CursorRight); + + MenuView menuView2 = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView2).isNotNull(); + assertThat(menuView2.getItems().get(0).getTitle()).isEqualTo("sub2"); + } + + @Test + void menuHasPositionRelativeToHeader() { + MenuBarView view = new MenuBarView(new MenuBarItem[] { + new MenuBarItem("menu1", new MenuItem[] { + new MenuItem("sub11") + }), + new MenuBarItem("menu2", new MenuItem[] { + new MenuItem("sub21") + }) + }); + configure(view); + view.setRect(0, 0, 20, 1); + + MouseEvent click = mouseClick(7, 0); + MouseHandlerResult result = view.getMouseHandler().handle(MouseHandler.argsOf(click)); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + + MenuView menuView = (MenuView) ReflectionTestUtils.getField(view, MENUVIEW_FIELD); + assertThat(menuView).isNotNull().satisfies(m -> { + assertThat(m.getRect()).satisfies(r -> { + assertThat(r.x()).isEqualTo(7); + }); + }); + + } + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuViewTests.java new file mode 100644 index 000000000..119dd2ebb --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/MenuViewTests.java @@ -0,0 +1,350 @@ +/* + * Copyright 2023 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 java.util.Arrays; + +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.MenuView.MenuItem; +import org.springframework.shell.component.view.control.MenuView.MenuItemCheckStyle; +import org.springframework.shell.component.view.control.MenuView.MenuViewOpenSelectedItemEvent; +import org.springframework.shell.component.view.control.MenuView.MenuViewSelectedItemChangedEvent; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuViewTests extends AbstractViewTests { + + private static final String SELECTED_FIELD = "activeItemIndex"; + + @Nested + class Construction { + + @Test + void constructView() { + MenuView view; + + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1"), + MenuItem.of("sub2") + }); + assertThat(view.getItems()).hasSize(2); + + view = new MenuView(new MenuItem[] { + new MenuItem("sub1"), + new MenuItem("sub2") + }); + assertThat(view.getItems()).hasSize(2); + + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1", MenuItemCheckStyle.RADIO), + MenuItem.of("sub2", MenuItemCheckStyle.RADIO) + }); + assertThat(view.getItems()).hasSize(2); + + view = new MenuView(new MenuItem[] { + new MenuItem("sub1", MenuItemCheckStyle.RADIO), + new MenuItem("sub2", MenuItemCheckStyle.RADIO) + }); + assertThat(view.getItems()).hasSize(2); + } + + @Test + void constructUsingRunnable() { + MenuView view; + + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1", MenuItemCheckStyle.RADIO, () -> {}), + MenuItem.of("sub2", MenuItemCheckStyle.RADIO, () -> {}) + }); + assertThat(view.getItems()).hasSize(2); + } + + } + + @Nested + class Styling { + + @Test + void hasBorder() { + MenuItem menuItem = new MenuView.MenuItem("sub1"); + MenuView view = new MenuView(Arrays.asList(menuItem)); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + + @Test + void defaultItemCheckStyleIsNoCheck() { + MenuItem menuItem = new MenuView.MenuItem("sub1"); + MenuView view = new MenuView(Arrays.asList(menuItem)); + assertThat(view.getItems()).allSatisfy(item -> { + assertThat(item.getCheckStyle()).isEqualTo(MenuItemCheckStyle.NOCHECK); + }); + } + + } + + @Nested + class Selection { + + MenuView view; + + @BeforeEach + void setup() { + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1"), + MenuItem.of("sub2") + }); + configure(view); + view.setRect(0, 0, 10, 10); + } + + @Test + void firstItemShouldAlwaysBeSelected() { + MenuItem menuItem = new MenuView.MenuItem("sub1"); + MenuView view = new MenuView(Arrays.asList(menuItem)); + Integer selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + } + + @Test + void clickInItemSelects() { + handleMouseClick(view, 0, 2); + Integer selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(1); + } + + @Test + void downArrowMovesSelection() { + Integer selected; + + handleKey(view, Key.CursorDown); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(1); + } + + @Test + void upArrowMovesSelection() { + Integer selected; + + handleKey(view, Key.CursorUp); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(1); + } + + @Test + void wheelMovesSelection() { + Integer selected; + + handleMouseWheelDown(view, 0, 1); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(1); + + handleMouseWheelUp(view, 0, 1); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + } + + @Test + void selectionShouldNotMoveOutOfBounds() { + Integer selected; + + handleKey(view, Key.CursorDown); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(1); + + handleKey(view, Key.CursorDown); + selected = (Integer) ReflectionTestUtils.getField(view, SELECTED_FIELD); + assertThat(selected).isEqualTo(0); + } + + void canSelectManually() { + + } + } + + @Nested + class Checked { + + MenuItem sub1; + MenuItem sub2; + MenuItem sub3; + MenuItem sub4; + MenuItem sub5; + MenuItem sub6; + MenuView view; + + @BeforeEach + void setup() { + sub1 = MenuItem.of("sub1", MenuItemCheckStyle.NOCHECK); + sub2 = MenuItem.of("sub2", MenuItemCheckStyle.NOCHECK); + sub3 = MenuItem.of("sub3", MenuItemCheckStyle.CHECKED); + sub4 = MenuItem.of("sub4", MenuItemCheckStyle.CHECKED); + sub5 = MenuItem.of("sub5", MenuItemCheckStyle.RADIO); + sub6 = MenuItem.of("sub6", MenuItemCheckStyle.RADIO); + } + + @Test + void onlyNocheckDontAddPrefix() { + view = new MenuView(new MenuItem[] { sub1, sub2 }); + view.setShowBorder(true); + configure(view); + view.setRect(0, 0, 10, 10); + view.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("sub1", 0, 1, 5); + assertThat(forScreen(screen10x10)).hasHorizontalText("sub2", 0, 2, 5); + } + + @Test + void showsCheckedInRadio() { + sub5.setChecked(true); + view = new MenuView(new MenuItem[] { sub5, sub6 }); + view.setShowBorder(true); + configure(view); + view.setRect(0, 0, 10, 10); + view.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("[x] sub5", 0, 1, 9); + assertThat(forScreen(screen10x10)).hasHorizontalText("[ ] sub6", 0, 2, 9); + } + + @Test + void showsCheckedInNonRadio() { + sub4.setChecked(true); + view = new MenuView(new MenuItem[] { sub3, sub4 }); + view.setShowBorder(true); + configure(view); + view.setRect(0, 0, 10, 10); + view.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("[ ] sub3", 0, 1, 9); + assertThat(forScreen(screen10x10)).hasHorizontalText("[x] sub4", 0, 2, 9); + } + + @Test + void mixedAddPrefixforNocheck() { + view = new MenuView(new MenuItem[] { sub1, sub3, sub4 }); + view.setShowBorder(true); + configure(view); + view.setRect(0, 0, 10, 10); + view.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText(" sub1", 0, 1, 9); + assertThat(forScreen(screen10x10)).hasHorizontalText("[ ] sub3", 0, 2, 9); + assertThat(forScreen(screen10x10)).hasHorizontalText("[ ] sub4", 0, 3, 9); + } + + } + + @Nested + class Events { + + MenuView view; + + @BeforeEach + void setup() { + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1"), + MenuItem.of("sub2") + }); + configure(view); + view.setRect(0, 0, 10, 10); + } + + @Test + void handlesMouseClickInItem() { + MouseEvent click = mouseClick(0, 2); + + Flux actions = eventLoop + .viewEvents(MenuViewSelectedItemChangedEvent.class); + StepVerifier verifier = StepVerifier.create(actions) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + MouseHandlerResult result = handleMouseClick(view, click); + + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + verifier.verify(Duration.ofSeconds(1)); + } + + @Test + void keySelectSendsEvent() { + Flux actions = eventLoop + .viewEvents(MenuViewOpenSelectedItemEvent.class); + StepVerifier verifier = StepVerifier.create(actions) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + handleKey(view, Key.Enter); + verifier.verify(Duration.ofSeconds(1)); + } + + @Test + void selectionChangedSendsEvent() { + Flux actions = eventLoop + .viewEvents(MenuViewSelectedItemChangedEvent.class); + StepVerifier verifier = StepVerifier.create(actions) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + handleKey(view, Key.CursorDown); + verifier.verify(Duration.ofSeconds(1)); + } + + } + + @Nested + class Visual { + MenuView view; + + @BeforeEach + void setup() { + view = new MenuView(new MenuItem[] { + MenuItem.of("sub1"), + MenuItem.of("sub2") + }); + configure(view); + view.setRect(0, 0, 10, 10); + } + + @Test + void hasDefaultItemSelected() { + MenuItem menuItem = new MenuView.MenuItem("sub1"); + MenuView view = new MenuView(Arrays.asList(menuItem)); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasHorizontalText("sub1", 0, 1, 5); + } + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/StatusBarViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/StatusBarViewTests.java new file mode 100644 index 000000000..8dd74588b --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/StatusBarViewTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2023 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 java.util.Arrays; + +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.StatusBarView.StatusBarViewOpenSelectedItemEvent; +import org.springframework.shell.component.view.control.StatusBarView.StatusItem; +import org.springframework.shell.component.view.event.MouseEvent; +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatusBarViewTests extends AbstractViewTests { + + @Nested + class Construction { + + @Test + void constructView() { + StatusBarView view; + + view = new StatusBarView(); + assertThat(view.getItems()).hasSize(0); + + view = new StatusBarView(new StatusItem[] { + new StatusItem("item1") + }); + assertThat(view.getItems()).hasSize(1); + + view = new StatusBarView(Arrays.asList(new StatusItem("item1"))); + assertThat(view.getItems()).hasSize(1); + } + + } + + @Nested + class Internal { + + StatusBarView view; + StatusItem item; + + @Test + void itemPosition() { + view = new StatusBarView(new StatusItem[] { + new StatusItem("item1"), + new StatusItem("item2") + }); + view.setRect(0, 0, 10, 1); + + item = (StatusItem) ReflectionTestUtils.invokeMethod(view, "itemAt", 0, 0); + assertThat(item).isNotNull(); + assertThat(item.getTitle()).isEqualTo("item1"); + + item = (StatusItem) ReflectionTestUtils.invokeMethod(view, "itemAt", 7, 0); + assertThat(item).isNotNull(); + assertThat(item.getTitle()).isEqualTo("item2"); + } + + } + + @Nested + class Styling { + + @Test + void hasBorder() { + StatusBarView view = new StatusBarView(); + view.setShowBorder(true); + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + assertThat(forScreen(screen24x80)).hasBorder(0, 0, 80, 24); + } + } + + @Nested + class Events { + + StatusBarView view; + + @BeforeEach + void setup() { + view = new StatusBarView(new StatusItem[] { + new StatusItem("item1") + }); + configure(view); + view.setRect(0, 0, 20, 1); + } + + @Test + void handlesMouseClickInItem() { + MouseEvent click = mouseClick(1, 0); + + Flux actions = eventLoop + .viewEvents(StatusBarViewOpenSelectedItemEvent.class); + StepVerifier verifier = StepVerifier.create(actions) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + MouseHandlerResult result = handleMouseClick(view, click); + + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.event()).isEqualTo(click); + assertThat(r.consumed()).isTrue(); + assertThat(r.focus()).isNull(); + assertThat(r.capture()).isNull(); + }); + verifier.verify(Duration.ofSeconds(1)); + } + + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/cell/ListCellTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/cell/ListCellTests.java new file mode 100644 index 000000000..8fea15f55 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/cell/ListCellTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.control.AbstractViewTests; +import org.springframework.shell.component.view.screen.Color; +import org.springframework.shell.component.view.screen.ScreenItem; + +import static org.assertj.core.api.Assertions.assertThat; + +class ListCellTests extends AbstractViewTests { + + @Test + void simpleTextWrites() { + ListCell cell = new ListCell<>(); + cell.setRect(0, 0, 10, 1); + cell.updateItem("item"); + cell.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("item", 0, 0, 4); + } + + @Test + void hasBackgroundColor() { + ListCell cell = new ListCell<>(); + cell.setBackgroundColor(Color.BLUE); + cell.setRect(0, 0, 10, 1); + cell.updateItem("item"); + cell.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("item", 0, 0, 4).hasBackgroundColor(0, 0, Color.BLUE); + } + + @Test + void hasForegroundColor() { + ListCell cell = new ListCell<>(); + cell.setForegroundColor(Color.BLUE); + cell.setRect(0, 0, 10, 1); + cell.updateItem("item"); + cell.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("item", 0, 0, 4).hasForegroundColor(0, 0, Color.BLUE); + } + + @Test + void hasStyle() { + ListCell cell = new ListCell<>(); + cell.setStyle(ScreenItem.STYLE_BOLD); + cell.setRect(0, 0, 10, 1); + cell.updateItem("item"); + cell.draw(screen10x10); + assertThat(forScreen(screen10x10)).hasHorizontalText("item", 0, 0, 4).hasStyle(0, 0, ScreenItem.STYLE_BOLD); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/DefaultEventLoopTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/DefaultEventLoopTests.java new file mode 100644 index 000000000..66ea2ac6e --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/DefaultEventLoopTests.java @@ -0,0 +1,193 @@ +/* + * Copyright 2023 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.event; + +import java.time.Duration; +import java.util.Arrays; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.event.EventLoop.EventLoopProcessor; +import org.springframework.shell.component.view.message.ShellMessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultEventLoopTests { + + private DefaultEventLoop loop; + + @AfterEach + void clean() { + if (loop != null) { + loop.destroy(); + } + loop = null; + } + + private void initDefault() { + loop = new DefaultEventLoop(); + } + + @Test + void eventsGetIntoSingleSubscriber() { + initDefault(); + Message message = MessageBuilder.withPayload("TEST").build(); + + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + loop.dispatch(message); + verifier1.verify(Duration.ofSeconds(1)); + } + + @Test + void eventsGetIntoMultipleSubscriber() { + initDefault(); + Message message = MessageBuilder.withPayload("TEST").build(); + + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + StepVerifier verifier2 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + loop.dispatch(message); + verifier1.verify(Duration.ofSeconds(1)); + verifier2.verify(Duration.ofSeconds(1)); + } + + @Test + void canDispatchFlux() { + initDefault(); + Message message = MessageBuilder.withPayload("TEST").build(); + Flux> flux = Flux.just(message); + + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + loop.dispatch(flux); + verifier1.verify(Duration.ofSeconds(1)); + } + + @Test + void canDispatchMono() { + initDefault(); + Message message = MessageBuilder.withPayload("TEST").build(); + Mono> mono = Mono.just(message); + + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + + loop.dispatch(mono); + verifier1.verify(Duration.ofSeconds(1)); + } + + @Test + void subsribtionCompletesWhenLoopDestroyed() { + initDefault(); + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectComplete() + .verifyLater(); + + loop.destroy(); + verifier1.verify(Duration.ofSeconds(1)); + } + + static class TestEventLoopProcessor implements EventLoopProcessor { + + int count; + + @Override + public boolean canProcess(Message message) { + return true; + } + + @Override + public Flux> process(Message message) { + Message m = MessageBuilder.fromMessage(message) + .setHeader("count", count++) + .build(); + return Flux.just(m); + } + } + + @Test + void processorCreatesSameMessagesForAll() { + TestEventLoopProcessor processor = new TestEventLoopProcessor(); + loop = new DefaultEventLoop(Arrays.asList(processor)); + + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .assertNext(m -> { + Integer count = m.getHeaders().get("count", Integer.class); + assertThat(count).isEqualTo(0); + }) + .thenCancel() + .verifyLater(); + + StepVerifier verifier2 = StepVerifier.create(loop.events()) + .assertNext(m -> { + Integer count = m.getHeaders().get("count", Integer.class); + assertThat(count).isEqualTo(0); + }) + .thenCancel() + .verifyLater(); + + Message message = MessageBuilder.withPayload("TEST").build(); + loop.dispatch(message); + verifier1.verify(Duration.ofSeconds(1)); + verifier2.verify(Duration.ofSeconds(1)); + } + + @Test + void taskRunnableShouldExecute() { + initDefault(); + TestRunnable task = new TestRunnable(); + Message message = ShellMessageBuilder.withPayload(task).setEventType(EventLoop.Type.TASK).build(); + StepVerifier verifier1 = StepVerifier.create(loop.events()) + .expectNextCount(1) + .thenCancel() + .verifyLater(); + loop.dispatch(message); + verifier1.verify(Duration.ofSeconds(1)); + assertThat(task.count).isEqualTo(1); + } + + static class TestRunnable implements Runnable { + int count = 0; + + @Override + public void run() { + count++; + } + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyEventTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyEventTests.java new file mode 100644 index 000000000..0987a32cd --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyEventTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023 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.event; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyEvent.KeyMask; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyEventTests { + + @Test + void hasCtrl() { + assertThat(KeyEvent.of(Key.A).hasCtrl()).isFalse(); + assertThat(KeyEvent.of(Key.A | KeyMask.CtrlMask).hasCtrl()).isTrue(); + } + + @Test + void plainKey() { + assertThat(KeyEvent.of(Key.A).getPlainKey()).isEqualTo(Key.A); + assertThat(KeyEvent.of(Key.A | KeyMask.CtrlMask).getPlainKey()).isEqualTo(Key.A); + } + + @Test + void isKey() { + assertThat(KeyEvent.of(Key.A).isKey(Key.A)).isTrue(); + assertThat(KeyEvent.of(Key.A | KeyMask.CtrlMask).isKey(Key.A)).isTrue(); + assertThat(KeyEvent.of(Key.CursorDown).isKey(Key.CursorDown)).isTrue(); + assertThat(KeyEvent.of(Key.CursorLeft).isKey(Key.CursorRight)).isFalse(); + + } + + @Test + void isKey2() { + assertThat(KeyEvent.of(Key.A).isKey()).isTrue(); + assertThat(KeyEvent.of(Key.Backspace).isKey()).isFalse(); + + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyHandlerTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyHandlerTests.java new file mode 100644 index 000000000..a5993e1e2 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/KeyHandlerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023 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.event; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.event.KeyHandler.KeyHandlerArgs; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyHandlerTests { + + private static final KeyEvent EVENT = KeyEvent.of(Key.x); + private static final KeyHandlerArgs ARGS = KeyHandler.argsOf(EVENT); + + @Test + void handlesOtherIfThisConsumes() { + TestKeyHandler h1 = new TestKeyHandler(true); + TestKeyHandler h2 = new TestKeyHandler(false); + KeyHandler composed = h1.thenIfConsumed(h2); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(1); + assertThat(h2.calls).isEqualTo(1); + } + + @Test + void doesNotHandlesOtherIfThisDoesNotConsume() { + TestKeyHandler h1 = new TestKeyHandler(true); + TestKeyHandler h2 = new TestKeyHandler(false); + KeyHandler composed = h2.thenIfConsumed(h1); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(0); + assertThat(h2.calls).isEqualTo(1); + } + + @Test + void handlesOtherIfThisDoesNotConsume() { + TestKeyHandler h1 = new TestKeyHandler(true); + TestKeyHandler h2 = new TestKeyHandler(false); + KeyHandler composed = h2.thenIfNotConsumed(h1); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(1); + assertThat(h2.calls).isEqualTo(1); + } + + private static class TestKeyHandler implements KeyHandler { + + boolean willConsume; + int calls; + + TestKeyHandler(boolean willConsume) { + this.willConsume = willConsume; + } + + @Override + public KeyHandlerResult handle(KeyHandlerArgs args) { + calls++; + return KeyHandler.resultOf(args.event(), willConsume, null); + } + + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseEventTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseEventTests.java new file mode 100644 index 000000000..3bd9fe692 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseEventTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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.event; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.MouseEvent.Button; +import org.springframework.shell.component.view.event.MouseEvent.Modifier; +import org.springframework.shell.component.view.event.MouseEvent.Type; + +import static org.assertj.core.api.Assertions.assertThat; + +class MouseEventTests { + + @Test + void hasType() { + assertThat(MouseEvent.of(0, 0, 0).hasType()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Modifier.Shift).hasType()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Alt).hasType()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Control).hasType()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Type.Released).hasType()).isTrue(); + assertThat(MouseEvent.of(0, 0, Type.Pressed).hasType()).isTrue(); + assertThat(MouseEvent.of(0, 0, Type.Wheel).hasType()).isTrue(); + assertThat(MouseEvent.of(0, 0, Type.Moved).hasType()).isTrue(); + assertThat(MouseEvent.of(0, 0, Type.Dragged).hasType()).isTrue(); + + assertThat(MouseEvent.of(0, 0, Button.Button1).hasType()).isFalse(); + assertThat(MouseEvent.of(0, 0, Button.Button2).hasType()).isFalse(); + assertThat(MouseEvent.of(0, 0, Button.Button3).hasType()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Button.Button3 | Modifier.Control).hasType()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Shift | Type.Dragged).hasType()).isTrue(); + } + + @Test + void hasButton() { + assertThat(MouseEvent.of(0, 0, 0).hasButton()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Modifier.Shift).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Alt).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Control).hasButton()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Type.Released).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Pressed).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Wheel).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Moved).hasButton()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Dragged).hasButton()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Button.Button1).hasButton()).isTrue(); + assertThat(MouseEvent.of(0, 0, Button.Button2).hasButton()).isTrue(); + assertThat(MouseEvent.of(0, 0, Button.Button3).hasButton()).isTrue(); + + assertThat(MouseEvent.of(0, 0, Button.Button3 | Modifier.Control).hasButton()).isTrue(); + assertThat(MouseEvent.of(0, 0, Modifier.Shift | Type.Dragged).hasButton()).isFalse(); + } + + @Test + void hasModifier() { + assertThat(MouseEvent.of(0, 0, 0).hasModifier()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Modifier.Shift).hasModifier()).isTrue(); + assertThat(MouseEvent.of(0, 0, Modifier.Alt).hasModifier()).isTrue(); + assertThat(MouseEvent.of(0, 0, Modifier.Control).hasModifier()).isTrue(); + + assertThat(MouseEvent.of(0, 0, Type.Released).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Pressed).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Wheel).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Moved).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Type.Dragged).hasModifier()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Button.Button1).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Button.Button2).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Button.Button3).hasModifier()).isFalse(); + + assertThat(MouseEvent.of(0, 0, Button.Button3 | Type.Dragged).hasModifier()).isFalse(); + assertThat(MouseEvent.of(0, 0, Modifier.Shift | Type.Dragged).hasModifier()).isTrue(); + } + + @Test + void testBaseIdeas() { + assertThat(MouseEvent.of(0, 0, Modifier.Shift).has(Modifier.Shift)).isTrue(); + assertThat(MouseEvent.of(0, 0, Modifier.Shift).has(Modifier.Alt)).isFalse(); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseHandlerTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseHandlerTests.java new file mode 100644 index 000000000..8a4a716da --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/event/MouseHandlerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023 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.event; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerArgs; + +import static org.assertj.core.api.Assertions.assertThat; + +class MouseHandlerTests { + + private static final MouseEvent EVENT = MouseEvent.of(0, 0, 0); + private static final MouseHandlerArgs ARGS = MouseHandler.argsOf(EVENT); + + @Test + void handlesOtherIfThisConsumes() { + TestMouseHandler h1 = new TestMouseHandler(true); + TestMouseHandler h2 = new TestMouseHandler(false); + MouseHandler composed = h1.thenIfConsumed(h2); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(1); + assertThat(h2.calls).isEqualTo(1); + } + + @Test + void doesNotHandlesOtherIfThisDoesNotConsume() { + TestMouseHandler h1 = new TestMouseHandler(true); + TestMouseHandler h2 = new TestMouseHandler(false); + MouseHandler composed = h2.thenIfConsumed(h1); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(0); + assertThat(h2.calls).isEqualTo(1); + } + + @Test + void handlesOtherIfThisDoesNotConsume() { + TestMouseHandler h1 = new TestMouseHandler(true); + TestMouseHandler h2 = new TestMouseHandler(false); + MouseHandler composed = h2.thenIfNotConsumed(h1); + composed.handle(ARGS); + assertThat(h1.calls).isEqualTo(1); + assertThat(h2.calls).isEqualTo(1); + } + + private static class TestMouseHandler implements MouseHandler { + + boolean willConsume; + int calls; + + TestMouseHandler(boolean willConsume) { + this.willConsume = willConsume; + } + + @Override + public MouseHandlerResult handle(MouseHandlerArgs args) { + calls++; + return MouseHandler.resultOf(args.event(), willConsume, null, null); + } + + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/screen/ScreenTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/screen/ScreenTests.java new file mode 100644 index 000000000..c09c8e1f1 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/screen/ScreenTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2023 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.screen; + +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.control.AbstractViewTests; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.geom.VerticalAlign; +import org.springframework.shell.component.view.screen.Screen.Writer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScreenTests extends AbstractViewTests { + + @Test + void zeroDataSizeDoesntBreak() { + assertThat(screen0x0.getItems()).isEmpty(); + } + + @Test + void cantResizeNegative() { + assertThatThrownBy(() -> { + screen0x0.resize(-1, 0); + }).hasMessageContaining("negative rows"); + assertThatThrownBy(() -> { + screen0x0.resize(0, -1); + }).hasMessageContaining("negative columns"); + } + + @Test + void printInBoxShows() { + screen10x10.writerBuilder().build().border(0, 0, 10, 10); + assertThat(forScreen(screen10x10)).hasBorder(0, 0, 10, 10); + } + + @Test + void printsText() { + screen24x80.writerBuilder().build().text("text", 0, 0); + assertThat(forScreen(screen24x80)).hasHorizontalText("text", 0, 0, 4); + } + + @Test + void printsTextWithForegroundColor() { + Writer writer = screen24x80.writerBuilder().color(Color.RED).build(); + writer.text("text", 0, 0); + assertThat(forScreen(screen24x80)).hasForegroundColor(0, 0, Color.RED); + } + + @Test + void printsTextWithForegroundColorOnLayerTopOverrides() { + Writer writer0 = screen24x80.writerBuilder().layer(0).color(Color.BLACK).build(); + Writer writer1 = screen24x80.writerBuilder().layer(1).color(Color.RED).build(); + writer0.text("text", 0, 0); + assertThat(forScreen(screen24x80)).hasForegroundColor(0, 0, Color.BLACK); + writer1.text("text", 0, 0); + assertThat(forScreen(screen24x80)).hasForegroundColor(0, 0, Color.RED); + } + + @Test + void printsTextAlign() { + Rectangle rect = new Rectangle(1, 1, 10, 10); + screen24x80.writerBuilder().build().text("text", rect, HorizontalAlign.CENTER, VerticalAlign.CENTER); + assertThat(forScreen(screen24x80)).hasHorizontalText("text", 3, 5, 4); + } + + @Test + void printsTextAlignInOneRowRect() { + Rectangle rect = new Rectangle(1, 1, 10, 1); + screen24x80.writerBuilder().build().text("text", rect, HorizontalAlign.CENTER, VerticalAlign.CENTER); + assertThat(forScreen(screen24x80)).hasHorizontalText("text", 3, 1, 4); + } + + @Test + void writeTextFromWriterLayerOverrides() { + DefaultScreen screen = new DefaultScreen(24, 80); + screen.writerBuilder().layer(0).build().text("text", 0, 0); + screen.writerBuilder().layer(1).build().text("xxx", 0, 0); + ScreenItem[][] items = screen.getScreenItems(); + assertThat(items[0][0].getContent()).isEqualTo("x"); + assertThat(items[0][1].getContent()).isEqualTo("x"); + assertThat(items[0][2].getContent()).isEqualTo("x"); + assertThat(items[0][3].getContent()).isEqualTo("t"); + } + +} diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-catalog.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-catalog.adoc new file mode 100644 index 000000000..21b205c81 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-catalog.adoc @@ -0,0 +1,12 @@ +[#appendix-tui-catalog] +=== Catalog App +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Catalog application is showing various ways how Terminal UI Framework can be used. +In this section we discuss how this application works. It can be considered to be +a reference application as it's using most of the features available and tries +to follow best practices. + +==== Create Scenario +Every `Scenario` essentially is a sample code of a `View` as that's what catalog +app demonstrates. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-control.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-control.adoc new file mode 100644 index 000000000..021b30a21 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-control.adoc @@ -0,0 +1,5 @@ +[#appendix-tui-control] +=== Control +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_Control_ draws something into a screen with a given bounds. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-eventloop.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-eventloop.adoc new file mode 100644 index 000000000..80d49f385 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-eventloop.adoc @@ -0,0 +1,5 @@ +[#appendix-tui-eventloop] +=== EventLoop +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_EventLoop_ is a central system handling eventing in a framework. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-keyhandling.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-keyhandling.adoc new file mode 100644 index 000000000..5f999c51f --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-keyhandling.adoc @@ -0,0 +1,5 @@ +[#appendix-tui-keyhandling] +=== Key Handling +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Handles incoming key events. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-mousehandling.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-mousehandling.adoc new file mode 100644 index 000000000..5fba7f7aa --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-mousehandling.adoc @@ -0,0 +1,5 @@ +[#appendix-tui-mousehandling] +=== Mouse Handling +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Handles incoming mouse events. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-screen.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-screen.adoc new file mode 100644 index 000000000..6b38905a1 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-screen.adoc @@ -0,0 +1,6 @@ +[#appendix-tui-screen] +=== Screen +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_Screen_ is an abstraction providing higher level concept to draw something +into a terminal. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui-view.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui-view.adoc new file mode 100644 index 000000000..bc83a5aea --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui-view.adoc @@ -0,0 +1,5 @@ +[#appendix-tui-view] +=== View +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_View_ extends _Control_ providing integration into event loop. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-tui.adoc b/spring-shell-docs/src/main/asciidoc/appendices-tui.adoc new file mode 100644 index 000000000..0ffe605f9 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-tui.adoc @@ -0,0 +1,22 @@ +[appendix] +[#appendix-tech-intro-tui] +== Terminal UI +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +This is a technical introduction to _UI Framework_. + +_UI Framework_ is a toolkit to build rich console apps. + +include::appendices-tui-control.adoc[] + +include::appendices-tui-view.adoc[] + +include::appendices-tui-eventloop.adoc[] + +include::appendices-tui-screen.adoc[] + +include::appendices-tui-keyhandling.adoc[] + +include::appendices-tui-mousehandling.adoc[] + +include::appendices-tui-catalog.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/appendices.adoc b/spring-shell-docs/src/main/asciidoc/appendices.adoc index 10911ecc9..ba48fdfe4 100644 --- a/spring-shell-docs/src/main/asciidoc/appendices.adoc +++ b/spring-shell-docs/src/main/asciidoc/appendices.adoc @@ -1,3 +1,5 @@ include::appendices-techical-intro.adoc[] include::appendices-debugging.adoc[] + +include::appendices-tui.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-tui-intro.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-tui-intro.adoc new file mode 100644 index 000000000..6c2d803cc --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-tui-intro.adoc @@ -0,0 +1,14 @@ +[[using-shell-tui-intro]] +=== Introduction +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Lets start with a simple app which prints "hello world" in a view. +==== +[source, java, indent=0] +---- +include::{snippets}/TerminalUiSnippets.java[tag=snippet1] +---- +==== + +There is not much to see here other than `TerminalUI` is a class handling +all logic aroung views and uses `View` as it's root view. diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-box.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-box.adoc new file mode 100644 index 000000000..719f99fe6 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-box.adoc @@ -0,0 +1,6 @@ +[[using-shell-tui-views-box]] +==== BoxView +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_BoxView_ is a base implementation providing functionality to draw into a +bounded _Rectancle_. diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-list.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-list.adoc new file mode 100644 index 000000000..1a2504c04 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views-list.adoc @@ -0,0 +1,5 @@ +[[using-shell-tui-views-list]] +==== ListView +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +_ListView_ is a base implementation providing functionality to draw list of items. diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-tui-views.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views.adoc new file mode 100644 index 000000000..1b806c3ea --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-tui-views.adoc @@ -0,0 +1,11 @@ +[[using-shell-tui-views]] +=== Views +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Framework provides a build-in views which are documented below. + +TIP: To learn more about views, see <>. + +include::using-shell-tui-views-box.adoc[] + +include::using-shell-tui-views-list.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-tui.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-tui.adoc new file mode 100644 index 000000000..2fa4aacb4 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-tui.adoc @@ -0,0 +1,16 @@ +[[using-shell-tui]] +== Terminal UI + +NOTE: Feature is experimental and subject to breaking changes until foundation + and related concepts around framework are getting more stable. + +_Terminal UI Framework_ is a toolkit to build rich console apps. This section is +for those using existing features as is. If you're planning to go deeper possibly +creating your own components <> provides more detailed +documentation. + +TIP: Catalog sample is a good place to study a real application <>. + +include::using-shell-tui-intro.adoc[] + +include::using-shell-tui-views.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/using-shell.adoc b/spring-shell-docs/src/main/asciidoc/using-shell.adoc index 1278d9186..c8497b12a 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell.adoc @@ -10,6 +10,8 @@ include::using-shell-building.adoc[] include::using-shell-components.adoc[] +include::using-shell-tui.adoc[] + include::using-shell-customization.adoc[] include::using-shell-execution.adoc[] diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/TerminalUiSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/TerminalUiSnippets.java new file mode 100644 index 000000000..4893e851a --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/TerminalUiSnippets.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 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.docs; + +import org.jline.terminal.Terminal; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.shell.component.view.TerminalUI; +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.VerticalAlign; + +class TerminalUiSnippets { + + class Sample { + + // tag::snippet1[] + @Autowired + Terminal terminal; + + void build() { + TerminalUI ui = new TerminalUI(terminal); + BoxView view = new BoxView(); + view.setDrawFunction((screen, rect) -> { + screen.writerBuilder() + .build() + .text("Hello World", rect, HorizontalAlign.CENTER, VerticalAlign.CENTER); + return rect; + }); + ui.setRoot(view, true); + ui.run(); + } + // end::snippet1[] + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java new file mode 100644 index 000000000..60b945893 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java @@ -0,0 +1,252 @@ +/* + * Copyright 2023 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.samples.catalog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.jline.terminal.Terminal; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.messaging.Message; +import org.springframework.shell.component.view.TerminalUI; +import org.springframework.shell.component.view.control.AppView; +import org.springframework.shell.component.view.control.AppView.AppViewEvent; +import org.springframework.shell.component.view.control.GridView; +import org.springframework.shell.component.view.control.ListView; +import org.springframework.shell.component.view.control.ListView.ListViewOpenSelectedItemEvent; +import org.springframework.shell.component.view.control.ListView.ListViewSelectedItemChangedEvent; +import org.springframework.shell.component.view.control.MenuBarView; +import org.springframework.shell.component.view.control.MenuBarView.MenuBarItem; +import org.springframework.shell.component.view.control.MenuView.MenuItem; +import org.springframework.shell.component.view.control.MenuView.MenuItemCheckStyle; +import org.springframework.shell.component.view.control.StatusBarView; +import org.springframework.shell.component.view.control.StatusBarView.StatusItem; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.control.cell.ListCell; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Catalog app logic. Builds a simple application ui where scenarios can be + * selected and run. + * + * @author Janne Valkealahti + */ +public class Catalog { + + // ref types helping with deep nested generics from events + private final static ParameterizedTypeReference> LISTVIEW_SCENARIO_TYPEREF + = new ParameterizedTypeReference>() {}; + private final static ParameterizedTypeReference> LISTVIEW_STRING_TYPEREF + = new ParameterizedTypeReference>() {}; + + // mapping from category name to scenarios(can belong to multiple categories) + private final Map> categoryMap = new TreeMap<>(); + private final Terminal terminal; + private View currentScenarioView = null; + private TerminalUI ui; + private ListView categories; + private EventLoop eventLoop; + + public Catalog(Terminal terminal, List scenarios) { + this.terminal = terminal; + mapScenarios(scenarios); + } + + private void mapScenarios(List scenarios) { + // we blindly expect scenario to have ScenarioComponent annotation with all fields + scenarios.forEach(sce -> { + ScenarioComponent ann = AnnotationUtils.findAnnotation(sce.getClass(), ScenarioComponent.class); + if (ann != null) { + String name = ann.name(); + String description = ann.description(); + String[] category = ann.category(); + if (StringUtils.hasText(name) && StringUtils.hasText(description) && !ObjectUtils.isEmpty(category)) { + for (String cat : category) { + ScenarioData scenarioData = new ScenarioData(sce, name, description, category); + categoryMap.computeIfAbsent(Scenario.CATEGORY_ALL, key -> new ArrayList<>()).add(scenarioData); + categoryMap.computeIfAbsent(cat, key -> new ArrayList<>()).add(scenarioData); + } + } + } + }); + } + + private void requestQuit() { + Message msg = ShellMessageBuilder.withPayload("int") + .setEventType(EventLoop.Type.SYSTEM) + .setPriority(0) + .build(); + eventLoop.dispatch(msg); + } + + /** + * Main run loop. Builds the ui and exits when user requests exit. + */ + public void run() { + ui = new TerminalUI(terminal); + eventLoop = ui.getEventLoop(); + AppView app = buildScenarioBrowser(eventLoop, ui); + + // handle logic to switch between main scenario browser + // and currently active scenario + eventLoop.onDestroy(eventLoop.keyEvents() + .doOnNext(m -> { + if (m.getPlainKey() == Key.q && m.hasCtrl()) { + if (currentScenarioView != null) { + currentScenarioView = null; + ui.setRoot(app, true); + } + else { + requestQuit(); + } + } + }) + .subscribe()); + + // start main scenario browser + ui.setRoot(app, true); + ui.setFocus(categories); + categories.setSelected(0); + ui.run(); + } + + private AppView buildScenarioBrowser(EventLoop eventLoop, TerminalUI component) { + // we use main app view to represent scenario browser + AppView app = new AppView(); + app.setEventLoop(eventLoop); + + // category selector on left, scenario selector on right + GridView grid = new GridView(); + grid.setRowSize(1, 0, 1); + grid.setColumnSize(30, 0); + + categories = buildCategorySelector(eventLoop); + ListView scenarios = buildScenarioSelector(eventLoop); + + // handle event when scenario is chosen + eventLoop.onDestroy(eventLoop.viewEvents(LISTVIEW_SCENARIO_TYPEREF, scenarios) + .subscribe(event -> { + View view = event.args().item().scenario().configure(eventLoop).build(); + component.setRoot(view, true); + currentScenarioView = view; + })); + + + // handle event when category selection is changed + eventLoop.onDestroy(eventLoop.viewEvents(LISTVIEW_STRING_TYPEREF, categories) + .subscribe(event -> { + if (event.args().item() != null) { + String selected = event.args().item(); + List list = categoryMap.get(selected); + scenarios.setItems(list); + } + })); + + // handle focus change between lists + eventLoop.onDestroy(eventLoop.viewEvents(AppViewEvent.class, app) + .subscribe(event -> { + switch (event.args().direction()) { + case NEXT -> ui.setFocus(scenarios); + case PREVIOUS -> ui.setFocus(categories); + } + } + )); + + // We place statusbar below categories and scenarios + MenuBarView menuBar = buildMenuBar(eventLoop); + StatusBarView statusBar = buildStatusBar(eventLoop); + grid.addItem(menuBar, 0, 0, 1, 2, 0, 0); + grid.addItem(categories, 1, 0, 1, 1, 0, 0); + grid.addItem(scenarios, 1, 1, 1, 1, 0, 0); + grid.addItem(statusBar, 2, 0, 1, 2, 0, 0); + app.setMain(grid); + return app; + } + + private ListView buildCategorySelector(EventLoop eventLoop) { + ListView categories = new ListView<>(); + categories.setEventLoop(eventLoop); + List items = List.copyOf(categoryMap.keySet()); + categories.setItems(items); + categories.setTitle("Categories"); + categories.setShowBorder(true); + return categories; + } + + private static class ScenarioListCell extends ListCell { + + @Override + public void draw(Screen screen) { + Rectangle rect = getRect(); + Writer writer = screen.writerBuilder().style(getStyle()).build(); + writer.text(String.format("%-20s %s", getItem().name(), getItem().description()), rect.x(), rect.y()); + writer.background(rect, getBackgroundColor()); + } + } + + private ListView buildScenarioSelector(EventLoop eventLoop) { + ListView scenarios = new ListView<>(); + scenarios.setEventLoop(eventLoop); + scenarios.setTitle("Scenarios"); + scenarios.setShowBorder(true); + scenarios.setCellFactory(list -> new ScenarioListCell()); + return scenarios; + } + + private MenuBarView buildMenuBar(EventLoop eventLoop) { + Runnable quitAction = () -> requestQuit(); + MenuBarView menuBar = MenuBarView.of( + MenuBarItem.of("File", + MenuItem.of("Quit", MenuItemCheckStyle.NOCHECK, quitAction)), + MenuBarItem.of("Theme", + MenuItem.of("Dump", MenuItemCheckStyle.RADIO), + MenuItem.of("Funky", MenuItemCheckStyle.RADIO) + ), + MenuBarItem.of("Help", + MenuItem.of("About")) + ); + + menuBar.setEventLoop(eventLoop); + return menuBar; + } + + private StatusBarView buildStatusBar(EventLoop eventLoop) { + Runnable quitAction = () -> requestQuit(); + StatusBarView statusBar = new StatusBarView(); + statusBar.setEventLoop(eventLoop); + StatusItem item1 = new StatusBarView.StatusItem("CTRL-Q Quit", quitAction); + StatusItem item2 = new StatusBarView.StatusItem("F10 Status Bar"); + statusBar.setItems(Arrays.asList(item1, item2)); + return statusBar; + } + + private record ScenarioData(Scenario scenario, String name, String description, String[] category){}; + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/CatalogCommand.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/CatalogCommand.java index 641bc9de8..e5be446e4 100644 --- a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/CatalogCommand.java +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/CatalogCommand.java @@ -15,16 +15,29 @@ */ package org.springframework.shell.samples.catalog; +import java.util.List; + import org.springframework.shell.command.annotation.Command; -import org.springframework.shell.command.annotation.Option; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.standard.AbstractShellComponent; +/** + * Main command access point to view showcase catalog. + * + * @author Janne Valkealahti + */ @Command -public class CatalogCommand { +public class CatalogCommand extends AbstractShellComponent { + + private final List scenarios; + + public CatalogCommand(List scenarios) { + this.scenarios = scenarios; + } - @Command - String catalog( - @Option() String arg - ) { - return String.format("Hi arg=%s", arg); + @Command(command = "catalog") + public void catalog() { + Catalog catalog = new Catalog(getTerminal(), scenarios); + catalog.run(); } } diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/SpringShellApplication.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/SpringShellApplication.java index 34d58e26e..df9cae58b 100644 --- a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/SpringShellApplication.java +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/SpringShellApplication.java @@ -18,8 +18,10 @@ import org.springframework.boot.Banner.Mode; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.shell.command.annotation.CommandScan; @SpringBootApplication +@CommandScan public class SpringShellApplication { public static void main(String[] args) throws Exception { diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/AbstractScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/AbstractScenario.java new file mode 100644 index 000000000..4680ca8f4 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/AbstractScenario.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 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.samples.catalog.scenario; + +import org.springframework.shell.component.view.event.EventLoop; + +/** + * Base implementation of a {@link Scenario} helping to avoid some bloatware. + * + * @author Janne Valkealahti + */ +public abstract class AbstractScenario implements Scenario { + + private EventLoop eventloop; + + @Override + public Scenario configure(EventLoop eventloop) { + this.eventloop = eventloop; + return this; + } + + protected EventLoop getEventloop() { + return eventloop; + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/Scenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/Scenario.java new file mode 100644 index 000000000..3f2c74f28 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/Scenario.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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.samples.catalog.scenario; + +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.EventLoop; + +/** + * {@link Scenario} participates in a catalog showcase. + * + * @author Janne Valkealahti + */ +public interface Scenario { + + // Common category names + public static final String CATEGORY_ALL = "All Scenarios"; + public static final String CATEGORY_LISTVIEW = "ListView"; + public static final String CATEGORY_BOXVIEW = "BoxView"; + public static final String CATEGORY_LAYOUT = "Layout"; + public static final String CATEGORY_OTHER = "Other"; + + /** + * Build a {@link View} to be shown with a scenario. + * + * @return view of a scenario + */ + View build(); + + /** + * Configure scenario. + * + * @param eventloop eventloop for scenario + * @return scenario for chaining + */ + Scenario configure(EventLoop eventloop); + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/ScenarioComponent.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/ScenarioComponent.java new file mode 100644 index 000000000..718cded35 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/ScenarioComponent.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 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.samples.catalog.scenario; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Indexed; + +/** + * Annotation needed for a scenarios to get hooked up into a catalog app. + * Typically all fields in this annotation needs to have content to get attached + * into a catalog app. + * + * @author Janne Valkealahti + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Indexed +@Component +public @interface ScenarioComponent { + + /** + * Define a name of a scenario. + * + * @return name of a scenario + */ + String name() default ""; + + /** + * Define a short description of a scenario. + * + * @return short description of a scenario + */ + String description() default ""; + + /** + * Define a categories of a scenario. + * + * @return categories of a scenario + */ + String[] category() default {}; +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/DrawFunctionScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/DrawFunctionScenario.java new file mode 100644 index 000000000..8bd9b12ae --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/DrawFunctionScenario.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.box; + +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.VerticalAlign; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +@ScenarioComponent(name = "Draw Function", description = "BoxView with DrawFunction", category = { + Scenario.CATEGORY_BOXVIEW }) +public class DrawFunctionScenario extends AbstractScenario { + + @Override + public View build() { + BoxView view = new BoxView(); + view.setDrawFunction((screen, rect) -> { + screen.writerBuilder().build().text("Hello World", rect, HorizontalAlign.CENTER, VerticalAlign.CENTER); + return rect; + }); + return view; + } +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/SimpleBoxViewScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/SimpleBoxViewScenario.java new file mode 100644 index 000000000..171aa2c17 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/box/SimpleBoxViewScenario.java @@ -0,0 +1,42 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.box; + +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.screen.Color; +import org.springframework.shell.component.view.screen.ScreenItem; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +@ScenarioComponent(name = "Simple boxview", description = "BoxView with color and style", category = { + Scenario.CATEGORY_BOXVIEW }) +public class SimpleBoxViewScenario extends AbstractScenario { + + @Override + public View build() { + BoxView box = new BoxView(); + box.setTitle("Title"); + box.setShowBorder(true); + box.setBackgroundColor(Color.KHAKI4); + box.setTitleColor(Color.RED); + box.setTitleStyle(ScreenItem.STYLE_BOLD | ScreenItem.STYLE_ITALIC); + box.setTitleAlign(HorizontalAlign.CENTER); + return box; + } +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/grid/SimpleGridViewScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/grid/SimpleGridViewScenario.java new file mode 100644 index 000000000..17cfe0ce0 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/grid/SimpleGridViewScenario.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.grid; + +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.control.GridView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.screen.Color; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +@ScenarioComponent(name = "Simple gridview", description = "GridView sample", category = { Scenario.CATEGORY_LAYOUT }) +public class SimpleGridViewScenario extends AbstractScenario { + + @Override + public View build() { + BoxView menu = new BoxView(); + menu.setBackgroundColor(Color.KHAKI4); + menu.setTitle("Menu"); + menu.setShowBorder(true); + + BoxView main = new BoxView(); + main.setBackgroundColor(Color.KHAKI4); + main.setTitle("Main"); + main.setShowBorder(true); + + BoxView sideBar = new BoxView(); + sideBar.setBackgroundColor(Color.KHAKI4); + sideBar.setTitle("Sidebar"); + sideBar.setShowBorder(true); + + BoxView header = new BoxView(); + header.setBackgroundColor(Color.KHAKI4); + header.setTitle("Header"); + header.setShowBorder(true); + + BoxView footer = new BoxView(); + footer.setBackgroundColor(Color.KHAKI4); + footer.setTitle("Footer"); + footer.setShowBorder(true); + + GridView grid = new GridView(); + grid.setBackgroundColor(Color.KHAKI3); + grid.setTitle("Grid"); + grid.setShowBorder(true); + + grid.setRowSize(3, 0, 3); + grid.setColumnSize(30, 0, 30); + // grid.setShowBorder(true); + grid.setShowBorders(true); + grid.addItem(header, 0, 0, 1, 3, 0, 0); + grid.addItem(footer, 2, 0, 1, 3, 0, 0); + + grid.addItem(menu, 0, 0, 0, 0, 0, 0); + grid.addItem(main, 1, 0, 1, 3, 0, 0); + grid.addItem(sideBar, 0, 0, 0, 0, 0, 0); + + grid.addItem(menu, 1, 0, 1, 1, 0, 100); + grid.addItem(main, 1, 1, 1, 1, 0, 100); + grid.addItem(sideBar, 1, 2, 1, 1, 0, 100); + return grid; + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/listview/SimpleListViewScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/listview/SimpleListViewScenario.java new file mode 100644 index 000000000..98c033608 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/listview/SimpleListViewScenario.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.listview; + +import java.util.Arrays; + +import org.springframework.shell.component.view.control.ListView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +@ScenarioComponent(name = "Basic", description = "Basic list", category = { Scenario.CATEGORY_LISTVIEW }) +public class SimpleListViewScenario extends AbstractScenario { + + @Override + public View build() { + ListView view = new ListView<>(); + view.setEventLoop(getEventloop()); + view.setItems(Arrays.asList("item1", "item2")); + return view; + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/ClockScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/ClockScenario.java new file mode 100644 index 000000000..5dc89853c --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/ClockScenario.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.other; + +import java.time.Duration; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import reactor.core.publisher.Flux; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.EventLoop; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.geom.HorizontalAlign; +import org.springframework.shell.component.view.geom.Rectangle; +import org.springframework.shell.component.view.geom.VerticalAlign; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.message.ShellMessageHeaderAccessor; +import org.springframework.shell.component.view.message.StaticShellMessageHeaderAccessor; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +@ScenarioComponent(name = "Clock", description = "Showing time ticks", category = { Scenario.CATEGORY_OTHER }) +public class ClockScenario extends AbstractScenario { + + @Override + public View build() { + // simply use a plain box view to draw a date using a custom + // draw function + BoxView root = new BoxView(); + root.setTitle("What's o'clock"); + root.setShowBorder(true); + + // store text to print + AtomicReference ref = new AtomicReference<>(); + + // dispatch dates as messages + Flux> dates = Flux.interval(Duration.ofSeconds(1)).map(l -> { + String date = new Date().toString(); + Message message = MessageBuilder + .withPayload(date) + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.USER) + .build(); + return message; + }); + getEventloop().dispatch(dates); + + // process dates + getEventloop().onDestroy(getEventloop().events() + .filter(m -> EventLoop.Type.USER.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .subscribe(m -> { + if (m.getPayload() instanceof String s) { + ref.set(s); + getEventloop().dispatch(ShellMessageBuilder.ofRedraw()); + } + })); + + // testing for animations for now + AtomicInteger animX = new AtomicInteger(); + getEventloop().onDestroy(getEventloop().events() + .filter(m -> EventLoop.Type.SYSTEM.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .filter(m -> m.getHeaders().containsKey("animationtick")) + .subscribe(m -> { + Object payload = m.getPayload(); + if (payload instanceof Integer i) { + animX.set(i); + getEventloop().dispatch(ShellMessageBuilder.ofRedraw()); + } + })); + + AtomicReference hAlign = new AtomicReference<>(HorizontalAlign.CENTER); + AtomicReference vAlign = new AtomicReference<>(VerticalAlign.CENTER); + + getEventloop().onDestroy(getEventloop().keyEvents() + .subscribe(event -> { + switch (event.key()) { + case Key.CursorDown -> { + if (vAlign.get() == VerticalAlign.TOP) { + vAlign.set(VerticalAlign.CENTER); + } + else if (vAlign.get() == VerticalAlign.CENTER) { + vAlign.set(VerticalAlign.BOTTOM); + } + } + case Key.CursorUp -> { + if (vAlign.get() == VerticalAlign.BOTTOM) { + vAlign.set(VerticalAlign.CENTER); + } + else if (vAlign.get() == VerticalAlign.CENTER) { + vAlign.set(VerticalAlign.TOP); + } + } + case Key.CursorLeft -> { + if (hAlign.get() == HorizontalAlign.RIGHT) { + hAlign.set(HorizontalAlign.CENTER); + } + else if (hAlign.get() == HorizontalAlign.CENTER) { + hAlign.set(HorizontalAlign.LEFT); + } + } + case Key.CursorRight -> { + Message animStart = MessageBuilder + .withPayload("") + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.SYSTEM) + .setHeader("animationstart", true) + .build(); + getEventloop().dispatch(animStart); + } + }; + + })); + + // draw current date + root.setDrawFunction((screen, rect) -> { + int a = animX.get(); + Rectangle r = new Rectangle(rect.x() + 1 + a, rect.y() + 1, rect.width() - 2, rect.height() - 2); + // Rectangle r = new View.Rectangle(rect.x() + 1, rect.y() + 1, rect.width() - 2, rect.height() - 2); + String s = ref.get(); + if (s != null) { + screen.writerBuilder().build().text(s, r, hAlign.get(), vAlign.get()); + } + return rect; + }); + return root; + } +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SimpleInputViewScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SimpleInputViewScenario.java new file mode 100644 index 000000000..91ba7c3bf --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SimpleInputViewScenario.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.other; + +import org.springframework.shell.component.view.control.InputView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent;; + +@ScenarioComponent(name = "Simple inputview", description = "InputView sample", category = { Scenario.CATEGORY_OTHER }) +public class SimpleInputViewScenario extends AbstractScenario { + + @Override + public View build() { + InputView view = new InputView(); + view.setEventLoop(getEventloop()); + view.setTitle("Input"); + view.setShowBorder(true); + return view; + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SnakeGameScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SnakeGameScenario.java new file mode 100644 index 000000000..922076bc1 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/SnakeGameScenario.java @@ -0,0 +1,272 @@ +/* + * Copyright 2023 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.samples.catalog.scenario.other; + +import java.time.Duration; +import java.util.LinkedList; + +import reactor.core.publisher.Flux; + +import org.springframework.shell.component.view.control.BoxView; +import org.springframework.shell.component.view.control.View; +import org.springframework.shell.component.view.event.KeyEvent.Key; +import org.springframework.shell.component.view.message.ShellMessageBuilder; +import org.springframework.shell.component.view.screen.Screen; +import org.springframework.shell.component.view.screen.Screen.Writer; +import org.springframework.shell.samples.catalog.scenario.AbstractScenario; +import org.springframework.shell.samples.catalog.scenario.Scenario; +import org.springframework.shell.samples.catalog.scenario.ScenarioComponent; + +/** + * Scenario implementing a classic snake game. + * + * Demonstrates how we can just use box view to draw something + * manually with its draw function. + * + * Game logic. + * 1. Snake starts in a center, initial direction needs arrow key + * 2. Arrows control snake direction + * 3. Eating a food crows a snake, new food is generated + * 4. Game ends if snake eats itself or goes out of bounds + * 5. Game ends if perfect score is established + * + * @author Janne Valkealahti + */ +@ScenarioComponent(name = "Snake", description = "Classic snake game", category = { Scenario.CATEGORY_OTHER }) +public class SnakeGameScenario extends AbstractScenario { + + @Override + public View build() { + SnakeGame snakeGame = new SnakeGame(10, 15); + BoxView view = new BoxView(); + view.setTitle("Snake"); + view.setShowBorder(true); + + // we're outside of a view so no bindings, + // just subscribe to events and handle what is needed. + getEventloop().onDestroy(getEventloop().keyEvents() + .subscribe(event -> { + Integer direction = switch (event.key()) { + case Key.CursorDown -> 1; + case Key.CursorUp -> -1; + case Key.CursorLeft -> -2; + case Key.CursorRight -> 2; + default -> 0; + }; + if (direction != null) { + snakeGame.update(direction); + } + })); + + // schedule game updates + getEventloop().onDestroy(Flux.interval(Duration.ofMillis(500)) + .subscribe(l -> { + snakeGame.update(0); + getEventloop().dispatch(ShellMessageBuilder.ofRedraw()); + } + )); + + // draw game area + view.setDrawFunction((screen, rect) -> { + snakeGame.draw(screen); + return rect; + }); + return view; + } + + private static class SnakeGame { + Board board; + Game game; + + SnakeGame(int rows, int cols) { + // snake starts from a center + Cell initial = new Cell(rows / 2, cols / 2, 1); + + Snake snake = new Snake(initial); + board = new Board(rows, cols, initial); + game = new Game(snake, board); + } + + void update(int direction) { + if (direction != 0) { + game.direction = direction; + } + game.update(); + } + + void draw(Screen screen) { + Cell[][] cells = board.cells; + + Writer writer = screen.writerBuilder().build(); + // draw game area border + writer.border(2, 2, board.cols + 2, board.rows + 2); + + // draw info + String info = game.gameOver ? "Game Over" : String.format("Points %s", game.points); + writer.text(info, 2, 1); + + // draw snake and food + for (int i = 0; i < cells.length; i++) { + for (int j = 0; j < cells[i].length; j++) { + Cell cell = cells[i][j]; + String c = ""; + if (cell.type == 1) { + c = "x"; + } + else if (cell.type == -1) { + c = "o"; + } + writer.text(c, j + 3, i + 3); + } + } + } + } + + private static class Cell { + final int row, col; + // 0 - empty, > 0 - snake, < 0 - food + int type; + + Cell(int row, int col, int type) { + this.row = row; + this.col = col; + this.type = type; + } + } + + private static class Board { + final int rows, cols; + Cell[][] cells; + + Board(int rows, int cols, Cell initial) { + this.rows = rows; + this.cols = cols; + cells = new Cell[rows][cols]; + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + cells[row][col] = new Cell(row, col, 0); + } + } + cells[initial.row][initial.col] = initial; + food(); + } + + void food() { + int row = 0, column = 0; + while (true) { + row = (int) (Math.random() * rows); + column = (int) (Math.random() * cols); + if (cells[row][column].type != 1) + break; + } + cells[row][column].type = -1; + } + } + + private static class Snake { + LinkedList cells = new LinkedList<>(); + Cell head; + + Snake(Cell cell) { + head = cell; + cells.add(head); + head.type = 1; + } + + void move(Cell cell, boolean grow) { + if (!grow) { + Cell tail = cells.removeLast(); + tail.type = 0; + } + head = cell; + head.type = 1; + cells.addFirst(head); + } + + boolean checkCrash(Cell next) { + for (Cell cell : cells) { + if (cell == next) { + return true; + } + } + return false; + } + } + + private static class Game { + Snake snake; + Board board; + int direction; + int points; + boolean gameOver; + + Game(Snake snake, Board board) { + this.snake = snake; + this.board = board; + this.direction = 0; + } + + void update() { + if (direction == 0) { + return; + } + Cell next = next(snake.head); + if (next == null || snake.checkCrash(next)) { + direction = 0; + gameOver = true; + } + else { + boolean foundFood = next.type == -1; + snake.move(next, foundFood); + if (foundFood) { + board.food(); + points++; + } + } + } + + Cell next(Cell cell) { + int row = cell.row; + int col = cell.col; + // return null if we're about to go out of bounds + if (direction == 2) { + col++; + if (col >= board.cols) { + return null; + } + } + else if (direction == -2) { + col--; + if (col < 0) { + return null; + } + } + else if (direction == 1) { + row++; + if (row >= board.rows) { + return null; + } + } + else if (direction == -1) { + row--; + if (row < 0) { + return null; + } + } + return board.cells[row][col]; + } + } +}