From ccf6d771d96ecadda7da8a33f162b58545f4e8f2 Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Tue, 7 Nov 2023 08:25:47 +0000 Subject: [PATCH] Add multiinput scenario - InputView using viewcommands and has event for text change. - Experimental way to handle tab navigation in a layout views. - New viewcommands for tab navigation and moving cursor. - Mouse click takes focus in AbstractView if no view command binding. - MultiInputViewScenario now shows tab navigation. - Fixes #917 --- .../shell/component/view/TerminalUI.java | 1 + .../component/view/control/AbstractView.java | 5 ++ .../component/view/control/GridView.java | 39 +++++++++- .../component/view/control/InputView.java | 66 +++++++++++++---- .../component/view/control/ViewCommand.java | 32 +++++++++ .../component/view/control/ViewService.java | 3 + .../view/control/AbstractViewTests.java | 2 +- .../component/view/control/BaseViewTests.java | 72 +++++++++++++++++++ .../view/control/InputViewTests.java | 27 +++++++ spring-shell-docs/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/tui/views/input.adoc | 66 +++++++++++++++++ .../shell/docs/InputViewSnippets.java | 33 +++++++++ .../other/MultiInputViewScenario.java | 55 ++++++++++++++ 13 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BaseViewTests.java create mode 100644 spring-shell-docs/modules/ROOT/pages/tui/views/input.adoc create mode 100644 spring-shell-docs/src/test/java/org/springframework/shell/docs/InputViewSnippets.java create mode 100644 spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/MultiInputViewScenario.java 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 index 9508e057..60b8b51a 100644 --- 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 @@ -196,6 +196,7 @@ public class TerminalUI implements ViewService { view.setViewService(getViewService()); } + @Override public void setFocus(@Nullable View view) { if (focus != null) { focus.focus(focus, false); 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 index 44754335..0e1042fb 100644 --- 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 @@ -164,6 +164,7 @@ public abstract class AbstractView extends AbstractControl implements View { int mouse = event.mouse(); View view = null; boolean consumed = false; + // mouse binding may consume and focus MouseBindingValue mouseBindingValue = getMouseBindings().get(mouse); if (mouseBindingValue != null) { if (mouseBindingValue.mousePredicate().test(event)) { @@ -171,6 +172,10 @@ public abstract class AbstractView extends AbstractControl implements View { consumed = dispatchMouseRunCommand(event, mouseBindingValue); } } + // click in bounds focuses + if (view == null && getRect().contains(event.x(), event.y())) { + view = this; + } return MouseHandler.resultOf(args.event(), consumed, view, this); }; return handler; 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 index bdebbf33..7d176d13 100644 --- 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 @@ -24,6 +24,7 @@ import java.util.Map.Entry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +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.event.MouseHandler.MouseHandlerResult; @@ -214,17 +215,53 @@ public class GridView extends BoxView { }; } + private void nextView() { + View toFocus = null; + boolean found = false; + for (GridItem i : gridItems) { + if (!i.visible) { + continue; + } + if (toFocus == null) { + toFocus = i.view; + } + if (found) { + toFocus = i.view; + break; + } + if (i.view.hasFocus()) { + found = true; + } + } + if (toFocus != null) { + getViewService().setFocus(toFocus); + } + } + + @Override + protected void initInternal() { + registerViewCommand(ViewCommand.NEXT_VIEW, () -> nextView()); + + registerKeyBinding(Key.Tab, ViewCommand.NEXT_VIEW); + } + @Override public KeyHandler getKeyHandler() { log.trace("getKeyHandler()"); + KeyHandler handler = null; for (GridItem i : gridItems) { if (i.view.hasFocus()) { - return i.view.getKeyHandler(); + handler = i.view.getKeyHandler(); + break; } } + if (handler != null) { + return handler.thenIfNotConsumed(super.getKeyHandler()); + } return super.getKeyHandler(); } + @Override public boolean hasFocus() { for (GridItem i : gridItems) { 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 index 6b332fde..f64e00d1 100644 --- 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 @@ -18,6 +18,9 @@ package org.springframework.shell.component.view.control; import java.util.ArrayList; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.springframework.shell.component.message.ShellMessageBuilder; import org.springframework.shell.component.view.event.KeyEvent; import org.springframework.shell.component.view.event.KeyEvent.Key; @@ -33,20 +36,28 @@ import org.springframework.shell.geom.Rectangle; */ public class InputView extends BoxView { + private final Logger log = LoggerFactory.getLogger(InputView.class); private final ArrayList text = new ArrayList<>(); private int cursorIndex = 0; @Override protected void initInternal() { - registerKeyBinding(Key.CursorLeft, event -> left()); - registerKeyBinding(Key.CursorRight, event -> right()); - registerKeyBinding(Key.Delete, () -> delete()); - registerKeyBinding(Key.Backspace, () -> backspace()); - registerKeyBinding(Key.Enter, () -> done()); + registerViewCommand(ViewCommand.ACCEPT, () -> done()); + registerViewCommand(ViewCommand.LEFT, () -> left()); + registerViewCommand(ViewCommand.RIGHT, () -> right()); + registerViewCommand(ViewCommand.DELETE_CHAR_LEFT, () -> deleteCharLeft()); + registerViewCommand(ViewCommand.DELETE_CHAR_RIGHT, () -> deleteCharRight()); + + registerKeyBinding(Key.Enter, ViewCommand.ACCEPT); + registerKeyBinding(Key.CursorLeft, ViewCommand.LEFT); + registerKeyBinding(Key.CursorRight, ViewCommand.RIGHT); + registerKeyBinding(Key.Backspace, ViewCommand.DELETE_CHAR_LEFT); + registerKeyBinding(Key.Delete, ViewCommand.DELETE_CHAR_RIGHT); } @Override public KeyHandler getKeyHandler() { + log.trace("getKeyHandler()"); KeyHandler handler = args -> { KeyEvent event = args.event(); boolean consumed = false; @@ -68,9 +79,11 @@ public class InputView extends BoxView { Rectangle rect = getInnerRect(); String s = getInputText(); screen.writerBuilder().build().text(s, rect.x(), rect.y()); - screen.setShowCursor(hasFocus()); - int cPos = cursorPosition(); - screen.setCursorPosition(new Position(rect.x() + cPos, rect.y())); + if (hasFocus()) { + screen.setShowCursor(true); + int cPos = cursorPosition(); + screen.setCursorPosition(new Position(rect.x() + cPos, rect.y())); + } super.drawInternal(screen); } @@ -87,21 +100,34 @@ public class InputView extends BoxView { return text.stream().limit(cursorIndex).mapToInt(text -> text.length()).sum(); } - private void add(String data) { - text.add(cursorIndex, data); - moveCursor(1); + private void dispatchTextChange(String oldText, String newText) { + dispatch(ShellMessageBuilder.ofView(this, InputViewTextChangeEvent.of(this, oldText, newText))); } - private void backspace() { + private void add(String data) { + String oldText = text.stream().collect(Collectors.joining()); + text.add(cursorIndex, data); + moveCursor(1); + String newText = text.stream().collect(Collectors.joining()); + dispatchTextChange(oldText, newText); + } + + private void deleteCharLeft() { if (cursorIndex > 0) { + String oldText = text.stream().collect(Collectors.joining()); text.remove(cursorIndex - 1); + String newText = text.stream().collect(Collectors.joining()); + dispatchTextChange(oldText, newText); } left(); } - private void delete() { + private void deleteCharRight() { if (cursorIndex < text.size()) { + String oldText = text.stream().collect(Collectors.joining()); text.remove(cursorIndex); + String newText = text.stream().collect(Collectors.joining()); + dispatchTextChange(oldText, newText); } } @@ -124,4 +150,18 @@ public class InputView extends BoxView { dispatch(ShellMessageBuilder.ofView(this, ViewDoneEvent.of(this))); } + public record InputViewTextChangeEventArgs(String oldText, String newText) implements ViewEventArgs { + + public static InputViewTextChangeEventArgs of(String oldText, String newText) { + return new InputViewTextChangeEventArgs(oldText, newText); + } + } + + public record InputViewTextChangeEvent(View view, InputViewTextChangeEventArgs args) implements ViewEvent { + + public static InputViewTextChangeEvent of(View view, String oldText, String newText) { + return new InputViewTextChangeEvent(view, InputViewTextChangeEventArgs.of(oldText, newText)); + } + } + } 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 index d46d2e1e..83ee8547 100644 --- 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 @@ -39,4 +39,36 @@ public final class ViewCommand { */ public static String LINE_DOWN = "LineDown"; + /** + * Move focus to the next view. + * + * For example using tab to navigate into next input field. + */ + public static String NEXT_VIEW = "NextView"; + + /** + * Accepts a current state. + */ + public static String ACCEPT = "Accept"; + + /** + * Deletes the character on the left. + */ + public static String DELETE_CHAR_LEFT = "DeleteCharLeft"; + + /** + * Deletes the character on the right. + */ + public static String DELETE_CHAR_RIGHT = "DeleteCharRight"; + + /** + * Moves the selection left by one. + */ + public static String LEFT = "Left"; + + /** + * Moves the selection righ by one. + */ + public static String RIGHT = "Right"; + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewService.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewService.java index 406bb963..0400502f 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewService.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/ViewService.java @@ -15,6 +15,8 @@ */ package org.springframework.shell.component.view.control; +import org.springframework.lang.Nullable; + /** * Provides services for a {@link View} like handling modals. * @@ -26,4 +28,5 @@ public interface ViewService { void setModal(View view); + void setFocus(@Nullable View view); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java index 175715dd..216dc845 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AbstractViewTests.java @@ -119,7 +119,7 @@ public class AbstractViewTests { } protected MouseHandlerResult handleMouseClick(View view, int x, int y) { - MouseEvent click = mouseClick(0, 2); + MouseEvent click = mouseClick(x, y); return handleMouseClick(view, click); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BaseViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BaseViewTests.java new file mode 100644 index 00000000..2c28a067 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/BaseViewTests.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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; +import org.springframework.shell.component.view.screen.Screen; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaseViewTests extends AbstractViewTests { + + TestView view; + + @Nested + class Mouse { + + @BeforeEach + void setup() { + view = new TestView(); + configure(view); + } + + @Test + void clickInBounds() { + view.setRect(0, 0, 80, 24); + MouseHandlerResult result = handleMouseClick(view, 0, 0); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.consumed()).isFalse(); + assertThat(r.focus()).isEqualTo(view); + assertThat(r.capture()).isEqualTo(view); + }); + } + + @Test + void clickOutOfBounds() { + view.setRect(0, 0, 80, 24); + MouseHandlerResult result = handleMouseClick(view, 100, 100); + assertThat(result).isNotNull().satisfies(r -> { + assertThat(r.consumed()).isFalse(); + assertThat(r.focus()).isNull(); + assertThat(r.capture()).isEqualTo(view); + }); + } + + } + + private static class TestView extends AbstractView { + + @Override + protected void drawInternal(Screen screen) { + } + + } + +} 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 index e30d8d3e..e771e9cc 100644 --- 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 @@ -37,6 +37,30 @@ class InputViewTests extends AbstractViewTests { InputView view; + @Nested + class Visual { + + @BeforeEach + void setup() { + view = new InputView(); + configure(view); + } + + @Test + void haveOneRow() { + view.setShowBorder(false); + view.setRect(0, 0, 80, 1); + view.focus(view, true); + + dispatchEvent(view, KeyEvent.of('1')); + view.draw(screen24x80); + + assertThat(forScreen(screen24x80)).hasHorizontalText("1", 0, 0, 1); + assertThat(forScreen(screen24x80)).hasCursorInPosition(1, 0); + } + + } + @Nested class Input { @@ -50,6 +74,7 @@ class InputViewTests extends AbstractViewTests { void shouldShowPlainText() { view.setShowBorder(true); view.setRect(0, 0, 80, 24); + view.focus(view, true); dispatchEvent(view, KeyEvent.of('1')); view.draw(screen24x80); @@ -62,6 +87,7 @@ class InputViewTests extends AbstractViewTests { void shouldShowUnicode() { view.setShowBorder(true); view.setRect(0, 0, 80, 24); + view.focus(view, true); dispatchEvent(view, KeyEvent.of('★')); view.draw(screen24x80); @@ -74,6 +100,7 @@ class InputViewTests extends AbstractViewTests { void shouldShowUnicodeEmoji() { view.setShowBorder(true); view.setRect(0, 0, 80, 24); + view.focus(view, true); dispatchEvent(view, KeyEvent.of("😂")); view.draw(screen24x80); diff --git a/spring-shell-docs/modules/ROOT/nav.adoc b/spring-shell-docs/modules/ROOT/nav.adoc index cf4088aa..6ad9f9b5 100644 --- a/spring-shell-docs/modules/ROOT/nav.adoc +++ b/spring-shell-docs/modules/ROOT/nav.adoc @@ -61,6 +61,7 @@ *** xref:tui/views/button.adoc[] *** xref:tui/views/dialog.adoc[] *** xref:tui/views/grid.adoc[] +*** xref:tui/views/input.adoc[] *** xref:tui/views/list.adoc[] *** xref:tui/views/menu.adoc[] *** xref:tui/views/menubar.adoc[] diff --git a/spring-shell-docs/modules/ROOT/pages/tui/views/input.adoc b/spring-shell-docs/modules/ROOT/pages/tui/views/input.adoc new file mode 100644 index 00000000..b68377b7 --- /dev/null +++ b/spring-shell-docs/modules/ROOT/pages/tui/views/input.adoc @@ -0,0 +1,66 @@ += InputView +:page-section-summary-toc: 1 + +ifndef::snippets[:snippets: ../../../../../src/test/java/org/springframework/shell/docs] + +_InputView_ is a base implementation providing functionality to draw and modify +text in a bounded _Rectancle_. + +[source, java, indent=0] +---- +include::{snippets}/InputViewSnippets.java[tag=sample] +---- + +== Default Bindings +Default _view commands_ are: + +.ViewCommands +|=== +|Command |Description + +|LEFT +|Cursor moves left + +|RIGHT +|Cursor moves right + +|DELETE_CHAR_LEFT +|Delete character left + +|DELETE_CHAR_RIGHT +|Delete character right + +|=== + +Default _key bindigs_ are: + +.Key +|=== +|Command |Description + +|CursorLeft +|Bound ViewCommand LEFT + +|CursorRight +|Bound ViewCommand RIGHT + +|Backspace +|Bound ViewCommand DELETE_CHAR_LEFT + +|Delete +|Bound ViewCommand DELETE_CHAR_RIGHT + +|=== + +== Events + +Events are sent depending on a used list type. + +.InputView Events +|=== +|Event |Description + +|InputViewTextChangeEvent +|Input text has changed + +|=== diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/InputViewSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/InputViewSnippets.java new file mode 100644 index 00000000..9e2b99cc --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/InputViewSnippets.java @@ -0,0 +1,33 @@ +/* + * 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.springframework.shell.component.view.control.InputView; + +class InputViewSnippets { + + class Dump1 { + + @SuppressWarnings("unused") + void dump1() { + // tag::sample[] + InputView input = new InputView(); + String text = input.getInputText(); + // end::sample[] + } + } + +} diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/MultiInputViewScenario.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/MultiInputViewScenario.java new file mode 100644 index 00000000..807b7514 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/scenario/other/MultiInputViewScenario.java @@ -0,0 +1,55 @@ +/* + * 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.GridView; +import org.springframework.shell.component.view.control.InputView; +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 = "Multi inputview", description = "Multi InputView sample", category = { Scenario.CATEGORY_OTHER }) +public class MultiInputViewScenario extends AbstractScenario { + + @Override + public View build() { + GridView grid = new GridView(); + configure(grid); + grid.setRowSize(1, 1, 1); + grid.setColumnSize(0); + + InputView input1 = new InputView(); + input1.setBackgroundColor(Color.GREEN); + configure(input1); + + InputView input2 = new InputView(); + input2.setBackgroundColor(Color.BLUE); + configure(input2); + + InputView input3 = new InputView(); + input3.setBackgroundColor(Color.RED); + configure(input3); + + grid.addItem(input1, 0, 0, 1, 1, 0, 0); + grid.addItem(input2, 1, 0, 1, 1, 0, 0); + grid.addItem(input3, 2, 0, 1, 1, 0, 0); + + return grid; + } + +}