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()); view.setViewService(getViewService());
} }
@Override
public void setFocus(@Nullable View view) { public void setFocus(@Nullable View view) {
if (focus != null) { if (focus != null) {
focus.focus(focus, false); focus.focus(focus, false);

View File

@@ -164,6 +164,7 @@ public abstract class AbstractView extends AbstractControl implements View {
int mouse = event.mouse(); int mouse = event.mouse();
View view = null; View view = null;
boolean consumed = false; boolean consumed = false;
// mouse binding may consume and focus
MouseBindingValue mouseBindingValue = getMouseBindings().get(mouse); MouseBindingValue mouseBindingValue = getMouseBindings().get(mouse);
if (mouseBindingValue != null) { if (mouseBindingValue != null) {
if (mouseBindingValue.mousePredicate().test(event)) { if (mouseBindingValue.mousePredicate().test(event)) {
@@ -171,6 +172,10 @@ public abstract class AbstractView extends AbstractControl implements View {
consumed = dispatchMouseRunCommand(event, mouseBindingValue); 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 MouseHandler.resultOf(args.event(), consumed, view, this);
}; };
return handler; return handler;

View File

@@ -24,6 +24,7 @@ import java.util.Map.Entry;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.KeyHandler;
import org.springframework.shell.component.view.event.MouseHandler; import org.springframework.shell.component.view.event.MouseHandler;
import org.springframework.shell.component.view.event.MouseHandler.MouseHandlerResult; 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 @Override
public KeyHandler getKeyHandler() { public KeyHandler getKeyHandler() {
log.trace("getKeyHandler()"); log.trace("getKeyHandler()");
KeyHandler handler = null;
for (GridItem i : gridItems) { for (GridItem i : gridItems) {
if (i.view.hasFocus()) { if (i.view.hasFocus()) {
return i.view.getKeyHandler(); handler = i.view.getKeyHandler();
break;
} }
} }
if (handler != null) {
return handler.thenIfNotConsumed(super.getKeyHandler());
}
return super.getKeyHandler(); return super.getKeyHandler();
} }
@Override @Override
public boolean hasFocus() { public boolean hasFocus() {
for (GridItem i : gridItems) { for (GridItem i : gridItems) {

View File

@@ -18,6 +18,9 @@ package org.springframework.shell.component.view.control;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.stream.Collectors; 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.message.ShellMessageBuilder;
import org.springframework.shell.component.view.event.KeyEvent; import org.springframework.shell.component.view.event.KeyEvent;
import org.springframework.shell.component.view.event.KeyEvent.Key; import org.springframework.shell.component.view.event.KeyEvent.Key;
@@ -33,20 +36,28 @@ import org.springframework.shell.geom.Rectangle;
*/ */
public class InputView extends BoxView { public class InputView extends BoxView {
private final Logger log = LoggerFactory.getLogger(InputView.class);
private final ArrayList<String> text = new ArrayList<>(); private final ArrayList<String> text = new ArrayList<>();
private int cursorIndex = 0; private int cursorIndex = 0;
@Override @Override
protected void initInternal() { protected void initInternal() {
registerKeyBinding(Key.CursorLeft, event -> left()); registerViewCommand(ViewCommand.ACCEPT, () -> done());
registerKeyBinding(Key.CursorRight, event -> right()); registerViewCommand(ViewCommand.LEFT, () -> left());
registerKeyBinding(Key.Delete, () -> delete()); registerViewCommand(ViewCommand.RIGHT, () -> right());
registerKeyBinding(Key.Backspace, () -> backspace()); registerViewCommand(ViewCommand.DELETE_CHAR_LEFT, () -> deleteCharLeft());
registerKeyBinding(Key.Enter, () -> done()); 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 @Override
public KeyHandler getKeyHandler() { public KeyHandler getKeyHandler() {
log.trace("getKeyHandler()");
KeyHandler handler = args -> { KeyHandler handler = args -> {
KeyEvent event = args.event(); KeyEvent event = args.event();
boolean consumed = false; boolean consumed = false;
@@ -68,9 +79,11 @@ public class InputView extends BoxView {
Rectangle rect = getInnerRect(); Rectangle rect = getInnerRect();
String s = getInputText(); String s = getInputText();
screen.writerBuilder().build().text(s, rect.x(), rect.y()); screen.writerBuilder().build().text(s, rect.x(), rect.y());
screen.setShowCursor(hasFocus()); if (hasFocus()) {
int cPos = cursorPosition(); screen.setShowCursor(true);
screen.setCursorPosition(new Position(rect.x() + cPos, rect.y())); int cPos = cursorPosition();
screen.setCursorPosition(new Position(rect.x() + cPos, rect.y()));
}
super.drawInternal(screen); super.drawInternal(screen);
} }
@@ -87,21 +100,34 @@ public class InputView extends BoxView {
return text.stream().limit(cursorIndex).mapToInt(text -> text.length()).sum(); return text.stream().limit(cursorIndex).mapToInt(text -> text.length()).sum();
} }
private void add(String data) { private void dispatchTextChange(String oldText, String newText) {
text.add(cursorIndex, data); dispatch(ShellMessageBuilder.ofView(this, InputViewTextChangeEvent.of(this, oldText, newText)));
moveCursor(1);
} }
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) { if (cursorIndex > 0) {
String oldText = text.stream().collect(Collectors.joining());
text.remove(cursorIndex - 1); text.remove(cursorIndex - 1);
String newText = text.stream().collect(Collectors.joining());
dispatchTextChange(oldText, newText);
} }
left(); left();
} }
private void delete() { private void deleteCharRight() {
if (cursorIndex < text.size()) { if (cursorIndex < text.size()) {
String oldText = text.stream().collect(Collectors.joining());
text.remove(cursorIndex); 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))); 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"; 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; package org.springframework.shell.component.view.control;
import org.springframework.lang.Nullable;
/** /**
* Provides services for a {@link View} like handling modals. * Provides services for a {@link View} like handling modals.
* *
@@ -26,4 +28,5 @@ public interface ViewService {
void setModal(View view); 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) { protected MouseHandlerResult handleMouseClick(View view, int x, int y) {
MouseEvent click = mouseClick(0, 2); MouseEvent click = mouseClick(x, y);
return handleMouseClick(view, click); 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; 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 @Nested
class Input { class Input {
@@ -50,6 +74,7 @@ class InputViewTests extends AbstractViewTests {
void shouldShowPlainText() { void shouldShowPlainText() {
view.setShowBorder(true); view.setShowBorder(true);
view.setRect(0, 0, 80, 24); view.setRect(0, 0, 80, 24);
view.focus(view, true);
dispatchEvent(view, KeyEvent.of('1')); dispatchEvent(view, KeyEvent.of('1'));
view.draw(screen24x80); view.draw(screen24x80);
@@ -62,6 +87,7 @@ class InputViewTests extends AbstractViewTests {
void shouldShowUnicode() { void shouldShowUnicode() {
view.setShowBorder(true); view.setShowBorder(true);
view.setRect(0, 0, 80, 24); view.setRect(0, 0, 80, 24);
view.focus(view, true);
dispatchEvent(view, KeyEvent.of('★')); dispatchEvent(view, KeyEvent.of('★'));
view.draw(screen24x80); view.draw(screen24x80);
@@ -74,6 +100,7 @@ class InputViewTests extends AbstractViewTests {
void shouldShowUnicodeEmoji() { void shouldShowUnicodeEmoji() {
view.setShowBorder(true); view.setShowBorder(true);
view.setRect(0, 0, 80, 24); view.setRect(0, 0, 80, 24);
view.focus(view, true);
dispatchEvent(view, KeyEvent.of("😂")); dispatchEvent(view, KeyEvent.of("😂"));
view.draw(screen24x80); view.draw(screen24x80);

View File

@@ -61,6 +61,7 @@
*** xref:tui/views/button.adoc[] *** xref:tui/views/button.adoc[]
*** xref:tui/views/dialog.adoc[] *** xref:tui/views/dialog.adoc[]
*** xref:tui/views/grid.adoc[] *** xref:tui/views/grid.adoc[]
*** xref:tui/views/input.adoc[]
*** xref:tui/views/list.adoc[] *** xref:tui/views/list.adoc[]
*** xref:tui/views/menu.adoc[] *** xref:tui/views/menu.adoc[]
*** xref:tui/views/menubar.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;
}
}