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
This commit is contained in:
Janne Valkealahti
2023-11-07 08:25:47 +00:00
parent 21d1ce8599
commit ccf6d771d9
13 changed files with 387 additions and 15 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<String> 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));
}
}
}

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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) {
}
}
}

View File

@@ -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);

View File

@@ -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[]

View File

@@ -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
|===

View File

@@ -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[]
}
}
}

View File

@@ -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;
}
}