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
This commit is contained in:
Janne Valkealahti
2023-07-15 16:42:39 +01:00
parent da04202822
commit a0bdbfc033
94 changed files with 9990 additions and 8 deletions

7
.vscode/launch.json vendored
View File

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

View File

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

View File

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

View File

@@ -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<Integer> 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<AttributedString> 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<String> 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<Integer> keyMap) {
keyBinder.bindAll(keyMap);
}
private boolean read(BindingReader bindingReader, KeyMap<Integer> 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<KeyEvent> 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()));
}
}

View File

@@ -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<Screen, Rectangle, Rectangle> drawFunction;
private boolean hasFocus;
private int layer;
private EventLoop eventLoop;
private Map<Integer, KeyBindingValue> keyBindings = new HashMap<>();
private Map<Integer, MouseBindingValue> 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<Screen, Rectangle, Rectangle> drawFunction) {
this.drawFunction = drawFunction;
}
/**
* Gets a draw function.
*
* @return null if function is not set
* @see #setDrawFunction(BiFunction)
*/
public BiFunction<Screen, Rectangle, Rectangle> 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<Integer, KeyBindingValue> getKeyBindings() {
return keyBindings;
}
record MouseBindingValue(String mouseCommand, MouseBindingConsumer mouseConsumer, Runnable mouseRunnable,
Predicate<MouseEvent> mousePredicate) {
static MouseBindingValue of(MouseBindingValue old, String mouseCommand, MouseBindingConsumer mouseConsumer,
Runnable mouseRunnable, Predicate<MouseEvent> 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<Integer, MouseBindingValue> 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<MouseEvent> 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<Runnable> 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<Runnable> message = ShellMessageBuilder
.withPayload(runnable)
.setEventType(EventLoop.Type.TASK)
.build();
dispatch(message);
return true;
}
KeyBindingConsumer keyConsumer = command.keyConsumer();
if (keyConsumer != null) {
Message<KeyBindingConsumerArgs> 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<Runnable> message = ShellMessageBuilder
.withPayload(runnable)
.setEventType(EventLoop.Type.TASK)
.build();
dispatch(message);
return true;
}
MouseBindingConsumer mouseConsumer = command.mouseConsumer();
if (mouseConsumer != null) {
Message<MouseBindingConsumerArgs> message = ShellMessageBuilder
.withPayload(new MouseBindingConsumerArgs(mouseConsumer, event))
.setEventType(EventLoop.Type.TASK)
.build();
dispatch(message);
return true;
}
return false;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<GridItem> 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<View, GridItem> 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<View, GridItem> 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<View, GridItem> 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;
}
}
}

View File

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

View File

@@ -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<T> extends BoxView {
private final static Logger log = LoggerFactory.getLogger(ListView.class);
private final List<T> items = new ArrayList<>();
private int selected = -1;
private final List<ListCell<T>> cells = new ArrayList<>();
private Function<ListView<T>, ListCell<T>> 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<T> 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<ListView<T>, ListCell<T>> factory) {
this.factory = factory;
}
public void setItems(List<T> items) {
this.items.clear();
this.items.addAll(items);
this.cells.clear();
for (T i : items) {
ListCell<T> 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>(T item) implements ViewEventArgs {
public static <T> ListViewItemEventArgs<T> of(T item) {
return new ListViewItemEventArgs<T>(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<T>(View view, ListViewItemEventArgs<T> args) implements ViewEvent {
public static <T> ListViewOpenSelectedItemEvent<T> of(View view, T item) {
return new ListViewOpenSelectedItemEvent<T>(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<T>(View view, ListViewItemEventArgs<T> args) implements ViewEvent {
public static <T> ListViewSelectedItemChangedEvent<T> of(View view, T item) {
return new ListViewSelectedItemChangedEvent<T>(view, ListViewItemEventArgs.of(item));
}
}
}

View File

@@ -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<MenuBarItem> 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<MenuBarItem> 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<MenuBarItem> 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<MenuBarItem> 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<MenuItem> 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<MenuItem> getItems() {
return items;
}
}
}

View File

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

View File

@@ -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<StatusItem> items = new ArrayList<>();
public StatusBarView() {
this(new StatusItem[0]);
}
public StatusBarView(StatusItem[] items) {
this(Arrays.asList(items));
}
public StatusBarView(List<StatusItem> 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<StatusItem> 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<StatusItem> items) {
this.items.clear();
this.items.addAll(items);
}
/**
* Gets a status items.
*
* @return the status items
*/
public List<StatusItem> 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));
}
}
}

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.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();
}

View File

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

View File

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

View File

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

View File

@@ -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<T> extends AbstractControl implements Cell<T> {
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;
}
}

View File

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

View File

@@ -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<T> extends AbstractCell<T> {
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();
}
}

View File

@@ -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<Message<?>> messageQueue = new PriorityQueue<>(MessageComparator.comparingPriority());
private final Many<Message<?>> many = Sinks.many().unicast().onBackpressureBuffer(messageQueue);
private Flux<Message<?>> sink;
// private final Sinks.Many<Boolean> 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<EventLoopProcessor> processors;
public DefaultEventLoop() {
this(null);
}
public DefaultEventLoop(List<EventLoopProcessor> 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<? extends Message<?>> 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<? extends Message<?>> messages) {
subscribeTo(messages);
}
@Override
public Flux<Message<?>> events() {
return sink
// .doFinally((s) -> subscribedSignal.tryEmitNext(sink.currentSubscriberCount() > 0))
;
}
@Override
public <T> Flux<T> events(EventLoop.Type type, Class<T> clazz) {
return events()
.filter(m -> type.equals(StaticShellMessageHeaderAccessor.getEventType(m)))
.map(m -> m.getPayload())
.ofType(clazz);
}
@Override
@SuppressWarnings("unchecked")
public <T> Flux<T> events(Type type, ParameterizedTypeReference<T> typeRef) {
ResolvableType resolvableType = ResolvableType.forType(typeRef);
return (Flux<T>) events()
.filter(m -> type.equals(StaticShellMessageHeaderAccessor.getEventType(m)))
.map(m -> m.getPayload())
.ofType(resolvableType.getRawClass());
}
@Override
public Flux<KeyEvent> keyEvents() {
return events()
.filter(m -> EventLoop.Type.KEY.equals(StaticShellMessageHeaderAccessor.getEventType(m)))
.map(m -> m.getPayload())
.ofType(KeyEvent.class);
}
@Override
public Flux<MouseEvent> mouseEvents() {
return events()
.filter(m -> EventLoop.Type.MOUSE.equals(StaticShellMessageHeaderAccessor.getEventType(m)))
.map(m -> m.getPayload())
.ofType(MouseEvent.class);
}
@Override
public <T extends ViewEvent> Flux<T> viewEvents(Class<T> clazz) {
return events(EventLoop.Type.VIEW, clazz);
}
@Override
public <T extends ViewEvent> Flux<T> viewEvents(ParameterizedTypeReference<T> typeRef) {
return events(EventLoop.Type.VIEW, typeRef);
}
@Override
public <T extends ViewEvent> Flux<T> viewEvents(Class<T> clazz, View filterBy) {
return events(EventLoop.Type.VIEW, clazz).filter(args -> args.view() == filterBy);
}
@Override
public <T extends ViewEvent> Flux<T> viewEvents(ParameterizedTypeReference<T> 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<? extends Message<?>> 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<? super Message<?>> 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<? extends Message<?>> 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<Message<?>> {
@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<Message<?>> comparingPriority() {
return new MessageComparator();
}
}
}

View File

@@ -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<? extends Message<?>> events();
/**
* Specialisation of {@link #events()} which returns type safe
* {@link KeyEvent}s.
*
* @return the key events from an event loop
*/
Flux<KeyEvent> keyEvents();
/**
* Specialisation of {@link #events()} which returns type safe
* {@link MouseEvent}s.
*
* @return the mouse events from an event loop
*/
Flux<MouseEvent> mouseEvents();
/**
* Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s.
*
* @param <T> the type to expect
* @param clazz the type class to filter
* @return the filtered events from an event loop
*/
<T extends ViewEvent> Flux<T> viewEvents(Class<T> clazz);
/**
* Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s.
*
* @param <T> the type to expect
* @param typeRef the parameterized type to filter
* @return the filtered events from an event loop
*/
<T extends ViewEvent> Flux<T> viewEvents(ParameterizedTypeReference<T> typeRef);
/**
* Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s.
*
* @param <T> 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
*/
<T extends ViewEvent> Flux<T> viewEvents(Class<T> clazz, View filterBy);
/**
* Specialisation of {@link #events()} which returns type safe {@link ViewEvent}s.
*
* @param <T> 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
*/
<T extends ViewEvent> Flux<T> viewEvents(ParameterizedTypeReference<T> typeRef, View filterBy);
/**
* Specialisation of {@link #events()} which returns type safe
* stream filtered by given eventloop message type and message
* payload class type.
*
* @param <T> 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
*/
<T> Flux<T> events(EventLoop.Type type, Class<T> clazz);
/**
* Specialisation of {@link #events()} which returns type safe
* stream filtered by given eventloop message type and message
* payload class type.
*
* @param <T> 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
*/
<T> Flux<T> events(EventLoop.Type type, ParameterizedTypeReference<T> 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<? extends Message<?>> 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<? extends Message<?>> process(Message<?> message);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<? extends Message<?>> 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();
});
}
}

View File

@@ -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<? extends Message<?>> 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<? extends Message<?>> 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<? extends Message<?>> 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<? extends Message<?>> 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <T> the payload type.
*
* @author Janne Valkealahti
*/
public final class ShellMessageBuilder<T> {
private final T payload;
private final ShellMessageHeaderAccessor headerAccessor;
@Nullable
private final Message<T> originalMessage;
private ShellMessageBuilder(T payload, @Nullable Message<T> 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 <T> The type of the payload.
* @param payload the payload for the new message
* @return A ShellMessageBuilder.
*/
public static <T> ShellMessageBuilder<T> withPayload(T payload) {
return new ShellMessageBuilder<>(payload, null);
}
/**
* Create a {@code redraw} message.
*
* @return a redraw message
*/
public static Message<String> 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<MouseEvent> 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<String> ofViewFocus(String action, View view) {
return new ShellMessageBuilder<>(action, null)
.setEventType(EventLoop.Type.SYSTEM)
.setView(view)
.build();
}
public ShellMessageBuilder<T> setPriority(Integer priority) {
return setHeader(ShellMessageHeaderAccessor.PRIORITY, priority);
}
public ShellMessageBuilder<T> setView(View view) {
return setHeader(ShellMessageHeaderAccessor.VIEW, view);
}
public ShellMessageBuilder<T> setEventType(EventLoop.Type type) {
return setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, type);
}
public ShellMessageBuilder<T> setHeader(String headerName, @Nullable Object headerValue) {
this.headerAccessor.setHeader(headerName, headerValue);
return this;
}
public Message<T> build() {
return new GenericMessage<>(this.payload, this.headerAccessor.toMap());
}
}

View File

@@ -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<String, String, String> TYPE_VERIFY_MESSAGE_FUNCTION =
(name, trailer) -> "The '" + name + trailer;
private Set<String> 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> T getHeader(String key, Class<T> 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<String, Object> toMap() {
if (ObjectUtils.isEmpty(this.readOnlyHeaders)) {
return super.toMap();
}
else {
Map<String, Object> headers = super.toMap();
for (String header : this.readOnlyHeaders) {
headers.remove(header);
}
return headers;
}
}
}

View File

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

View File

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

View File

@@ -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<AttributedString> getScreenLines() {
List<AttributedString> 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<Integer, Layer> 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;
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<ScreenAssert, Screen> {
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<AttributedString> 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<AttributedString> 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);
}
}
}

View File

@@ -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<ScreenAssert> forScreen(Screen screen) {
return () -> new ScreenAssert(screen);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> 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<String> 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<String> message = MessageBuilder.withPayload("TEST").build();
Flux<Message<String>> 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<String> message = MessageBuilder.withPayload("TEST").build();
Mono<Message<String>> 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<? extends Message<?>> 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<String> 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<TestRunnable> 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++;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
[#appendix-tui-keyhandling]
=== Key Handling
ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs]
Handles incoming key events.

View File

@@ -0,0 +1,5 @@
[#appendix-tui-mousehandling]
=== Mouse Handling
ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs]
Handles incoming mouse events.

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
include::appendices-techical-intro.adoc[]
include::appendices-debugging.adoc[]
include::appendices-tui.adoc[]

View File

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

View File

@@ -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_.

View File

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

View File

@@ -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 <<appendix-tui-view>>.
include::using-shell-tui-views-box.adoc[]
include::using-shell-tui-views-list.adoc[]

View File

@@ -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 <<appendix-tech-intro-tui>> provides more detailed
documentation.
TIP: Catalog sample is a good place to study a real application <<appendix-tui-catalog>>.
include::using-shell-tui-intro.adoc[]
include::using-shell-tui-views.adoc[]

View File

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

View File

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

View File

@@ -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<ListViewOpenSelectedItemEvent<ScenarioData>> LISTVIEW_SCENARIO_TYPEREF
= new ParameterizedTypeReference<ListViewOpenSelectedItemEvent<ScenarioData>>() {};
private final static ParameterizedTypeReference<ListViewSelectedItemChangedEvent<String>> LISTVIEW_STRING_TYPEREF
= new ParameterizedTypeReference<ListViewSelectedItemChangedEvent<String>>() {};
// mapping from category name to scenarios(can belong to multiple categories)
private final Map<String, List<ScenarioData>> categoryMap = new TreeMap<>();
private final Terminal terminal;
private View currentScenarioView = null;
private TerminalUI ui;
private ListView<String> categories;
private EventLoop eventLoop;
public Catalog(Terminal terminal, List<Scenario> scenarios) {
this.terminal = terminal;
mapScenarios(scenarios);
}
private void mapScenarios(List<Scenario> 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<String> 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<ScenarioData> 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<ScenarioData> 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<String> buildCategorySelector(EventLoop eventLoop) {
ListView<String> categories = new ListView<>();
categories.setEventLoop(eventLoop);
List<String> items = List.copyOf(categoryMap.keySet());
categories.setItems(items);
categories.setTitle("Categories");
categories.setShowBorder(true);
return categories;
}
private static class ScenarioListCell extends ListCell<ScenarioData> {
@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<ScenarioData> buildScenarioSelector(EventLoop eventLoop) {
ListView<ScenarioData> 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){};
}

View File

@@ -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 {
@Command
String catalog(
@Option() String arg
) {
return String.format("Hi arg=%s", arg);
private final List<Scenario> scenarios;
public CatalogCommand(List<Scenario> scenarios) {
this.scenarios = scenarios;
}
@Command(command = "catalog")
public void catalog() {
Catalog catalog = new Catalog(getTerminal(), scenarios);
catalog.run();
}
}

View File

@@ -18,8 +18,10 @@ package org.springframework.shell.samples.catalog;
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> view = new ListView<>();
view.setEventLoop(getEventloop());
view.setItems(Arrays.asList("item1", "item2"));
return view;
}
}

View File

@@ -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<String> ref = new AtomicReference<>();
// dispatch dates as messages
Flux<Message<?>> dates = Flux.interval(Duration.ofSeconds(1)).map(l -> {
String date = new Date().toString();
Message<String> 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<HorizontalAlign> hAlign = new AtomicReference<>(HorizontalAlign.CENTER);
AtomicReference<VerticalAlign> 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<String> 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;
}
}

View File

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

View File

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