From 4b927be80797d266e84de16a726fad990e2f6fae Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Sat, 22 Jul 2023 14:21:10 +0100 Subject: [PATCH] Hide statusbar in a catalog app - Can clear items from a GridView - Full refactor for AppView now using GridView internally and can hide/show menu/status. - Add function key bindings f1-f10 - Modify catalog app to use new features in an AppView, listen F10 key to toggle statusbar visibility. - Relates #805 - Relates #807 - Relates #811 --- .../shell/component/view/control/AppView.java | 114 +++++++++++---- .../component/view/control/GridView.java | 7 + .../shell/component/view/event/KeyBinder.java | 11 ++ .../shell/component/view/event/KeyEvent.java | 13 ++ .../component/view/control/AppViewTests.java | 138 ++++++++++++++++++ .../component/view/control/GridViewTests.java | 50 +++++++ .../shell/samples/catalog/Catalog.java | 44 ++++-- 7 files changed, 333 insertions(+), 44 deletions(-) create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AppViewTests.java diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java index 7d24f3e1..a32a5a05 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/AppView.java @@ -15,9 +15,6 @@ */ 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; @@ -25,6 +22,7 @@ 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.util.Assert; /** * {@link AppView} provides an opinionated terminal UI application view @@ -34,40 +32,69 @@ import org.springframework.shell.component.view.screen.Screen; */ public class AppView extends BoxView { - private final static Logger log = LoggerFactory.getLogger(AppView.class); + private GridView grid; private View main; - private View modal; + private View menu; + private View status; + private boolean menuVisible = true; + private boolean statusVisible = true; + + public AppView(View main, View menuBar, View statusBar) { + Assert.notNull(main, "Main view must be set"); + Assert.notNull(menuBar, "Menubar view must be set"); + Assert.notNull(statusBar, "Statusbar view must be set"); + this.main = main; + this.menu = menuBar; + this.status = statusBar; + initLayout(); + } + + private void initLayout() { + grid = new GridView(); + grid.setRowSize(1, 0, 1); + grid.setColumnSize(0); + grid.clearItems(); + if (menuVisible && statusVisible) { + grid.addItem(menu, 0, 0, 1, 1, 0, 0); + grid.addItem(main, 1, 0, 1, 1, 0, 0); + grid.addItem(status, 2, 0, 1, 1, 0, 0); + } + else if (!menuVisible && !statusVisible) { + grid.addItem(menu, 0, 0, 0, 0, 0, 0); + grid.addItem(main, 0, 0, 3, 1, 0, 0); + grid.addItem(status, 2, 0, 0, 0, 0, 0); + } + else if (menuVisible && !statusVisible) { + grid.addItem(menu, 0, 0, 1, 1, 0, 0); + grid.addItem(main, 1, 0, 2, 1, 0, 0); + grid.addItem(status, 2, 0, 0, 1, 0, 0); + } + else if (!menuVisible && statusVisible) { + grid.addItem(menu, 0, 0, 0, 1, 0, 0); + grid.addItem(main, 0, 0, 2, 1, 0, 0); + grid.addItem(status, 2, 0, 1, 1, 0, 0); + } + } @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); + if (grid != null) { + grid.setRect(rect.x(), rect.y(), rect.width(), rect.height()); + grid.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(); + MouseHandler handler = grid.getMouseHandler(); + return handler.thenIfNotConsumed(super.getMouseHandler()); } @Override public KeyHandler getKeyHandler() { - KeyHandler handler1 = args -> { + KeyHandler handler = args -> { KeyEvent event = args.event(); boolean consumed = false; if (event.isKey(Key.CursorLeft)) { @@ -80,25 +107,50 @@ public class AppView extends BoxView { } return KeyHandler.resultOf(event, consumed, null); }; - - KeyHandler handler2 = main != null ? main.getKeyHandler() : super.getKeyHandler(); - return handler2.thenIfNotConsumed(handler1); + KeyHandler otherHandler = main != null ? main.getKeyHandler() : super.getKeyHandler(); + return otherHandler.thenIfNotConsumed(handler); } @Override public boolean hasFocus() { - if (main != null) { - return main.hasFocus(); + if (grid != null) { + return grid.hasFocus(); } return super.hasFocus(); } - public void setMain(View main) { - this.main = main; + /** + * Sets visibility for a {@code menubar}. + * + * @param visible the menubar visibility + */ + public void setMenuBarVisible(boolean visible) { + menuVisible = visible; + initLayout(); } - public void setModal(View modal) { - this.modal = modal; + /** + * Sets visibility for a {@code statusbar}. + * + * @param visible the statusbar visibility + */ + public void setStatusBarVisible(boolean visible) { + statusVisible = visible; + initLayout(); + } + + /** + * Toggles a {@code menubar} visibility. + */ + public void toggleMenuBarVisibility() { + setMenuBarVisible(!menuVisible); + } + + /** + * Toggles a {@code statusbar} visibility. + */ + public void toggleStatusBarVisibility() { + setStatusBarVisible(!statusVisible); } /** diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java index 2754f0b1..2953ee42 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/control/GridView.java @@ -173,6 +173,13 @@ public class GridView extends BoxView { return this; } + /** + * Remove all items. + */ + public void clearItems() { + this.gridItems.clear(); + } + /** * Defines if borders is shown. * diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java index d5275938..6b32fc2f 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyBinder.java @@ -61,6 +61,17 @@ public class KeyBinder { keyMap.bind(KeyEvent.Key.CursorUp, key(terminal, Capability.key_up)); keyMap.bind(KeyEvent.Key.CursorDown, key(terminal, Capability.key_down)); + keyMap.bind(KeyEvent.Key.f1, key(terminal, Capability.key_f1)); + keyMap.bind(KeyEvent.Key.f2, key(terminal, Capability.key_f2)); + keyMap.bind(KeyEvent.Key.f3, key(terminal, Capability.key_f3)); + keyMap.bind(KeyEvent.Key.f4, key(terminal, Capability.key_f4)); + keyMap.bind(KeyEvent.Key.f5, key(terminal, Capability.key_f5)); + keyMap.bind(KeyEvent.Key.f6, key(terminal, Capability.key_f6)); + keyMap.bind(KeyEvent.Key.f7, key(terminal, Capability.key_f7)); + keyMap.bind(KeyEvent.Key.f8, key(terminal, Capability.key_f8)); + keyMap.bind(KeyEvent.Key.f9, key(terminal, Capability.key_f9)); + keyMap.bind(KeyEvent.Key.f10, key(terminal, Capability.key_f10)); + 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))); diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java index 46f706c7..c00a9141 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/view/event/KeyEvent.java @@ -112,6 +112,19 @@ public record KeyEvent(int key) { public static final int Tab = 0x100007; public static final int Backtab = 0x100008; + public static final int f1 = 0x100009; + public static final int f2 = 0x10000a; + public static final int f3 = 0x10000b; + public static final int f4 = 0x10000c; + public static final int f5 = 0x10000d; + public static final int f6 = 0x10000e; + public static final int f7 = 0x10000f; + public static final int f8 = 0x100010; + public static final int f9 = 0x100011; + public static final int f10 = 0x100012; + public static final int f11 = 0x100013; + public static final int f12 = 0x100014; + public static final int Char = 0x1000000; public static final int Mouse = 0x1000001; public static final int Unicode = 0x1000002; diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AppViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AppViewTests.java new file mode 100644 index 00000000..4da93125 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/AppViewTests.java @@ -0,0 +1,138 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +class AppViewTests extends AbstractViewTests { + + @Nested + class ItemPositions { + + @Test + void simpleSetup() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu).setRect(0, 0, 80, 1); + verify(main).setRect(0, 1, 80, 22); + verify(status).setRect(0, 23, 80, 1); + } + + @Test + void simpleSetupWithInnerBorder() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + main.setShowBorder(true); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu).setRect(0, 0, 80, 1); + verify(main).setRect(0, 1, 80, 22); + verify(status).setRect(0, 23, 80, 1); + assertThat(forScreen(screen24x80)).hasBorder(0, 1, 80, 22); + } + + } + + @Nested + class Visibility { + + @Test + void menuAndStatusVisible() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + view.setMenuBarVisible(true); + view.setStatusBarVisible(true); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu).setRect(0, 0, 80, 1); + verify(main).setRect(0, 1, 80, 22); + verify(status).setRect(0, 23, 80, 1); + } + + @Test + void menuAndStatusInvisible() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + view.setMenuBarVisible(false); + view.setStatusBarVisible(false); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu, never()).setRect(anyInt(), anyInt(), anyInt(), anyInt()); + verify(main).setRect(0, 0, 80, 24); + verify(status, never()).setRect(anyInt(), anyInt(), anyInt(), anyInt()); + } + + @Test + void onlyMenuVisible() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + view.setMenuBarVisible(true); + view.setStatusBarVisible(false); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu).setRect(0, 0, 80, 1); + verify(main).setRect(0, 1, 80, 23); + verify(status, never()).setRect(anyInt(), anyInt(), anyInt(), anyInt()); + } + + @Test + void onlyStatusVisible() { + BoxView menu = spy(new BoxView()); + BoxView main = spy(new BoxView()); + BoxView status = spy(new BoxView()); + AppView view = new AppView(main, menu, status); + view.setMenuBarVisible(false); + view.setStatusBarVisible(true); + + view.setRect(0, 0, 80, 24); + view.draw(screen24x80); + + verify(menu, never()).setRect(anyInt(), anyInt(), anyInt(), anyInt()); + verify(main).setRect(0, 0, 80, 23); + verify(status).setRect(0, 23, 80, 1); + } + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java index 285d568e..2c10a8f4 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/view/control/GridViewTests.java @@ -512,5 +512,55 @@ class GridViewTests extends AbstractViewTests { } + @Nested + class NestedGrids { + + @Test + void simpleNestedGrid() { + BoxView box = spy(new BoxView()); + + GridView grid2 = spy(new GridView()); + grid2.setColumnSize(0); + grid2.setRowSize(0); + grid2.addItem(box, 0, 0, 1, 1, 0, 0); + + GridView grid1 = spy(new GridView()); + grid1.setColumnSize(0); + grid1.setRowSize(0); + grid1.addItem(grid2, 0, 0, 1, 1, 0, 0); + + grid1.setRect(0, 0, 80, 24); + grid1.draw(screen24x80); + + verify(grid1).setRect(0, 0, 80, 24); + verify(grid2).setRect(0, 0, 80, 24); + verify(box).setRect(0, 0, 80, 24); + } + + @Test + void simpleNestedGridWithBorders() { + BoxView box = spy(new BoxView()); + box.setShowBorder(true); + + GridView grid2 = spy(new GridView()); + grid2.setColumnSize(0); + grid2.setRowSize(0); + grid2.setShowBorder(true); + grid2.addItem(box, 0, 0, 1, 1, 0, 0); + + GridView grid1 = spy(new GridView()); + grid1.setColumnSize(0); + grid1.setRowSize(0); + grid1.setShowBorder(true); + grid1.addItem(grid2, 0, 0, 1, 1, 0, 0); + + grid1.setRect(0, 0, 80, 24); + grid1.draw(screen24x80); + + verify(grid1).setRect(0, 0, 80, 24); + verify(grid2).setRect(1, 1, 78, 22); + verify(box).setRect(2, 2, 76, 20); + } + } } diff --git a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java index a3047dae..46b7cd34 100644 --- a/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java +++ b/spring-shell-samples/spring-shell-sample-catalog/src/main/java/org/springframework/shell/samples/catalog/Catalog.java @@ -22,6 +22,8 @@ import java.util.Map; import java.util.TreeMap; import org.jline.terminal.Terminal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.annotation.AnnotationUtils; @@ -42,6 +44,7 @@ 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; 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; @@ -66,6 +69,7 @@ public class Catalog { = new ParameterizedTypeReference>() {}; private final static ParameterizedTypeReference> LISTVIEW_STRING_TYPEREF = new ParameterizedTypeReference>() {}; + private final static Logger log = LoggerFactory.getLogger(Catalog.class); // mapping from category name to scenarios(can belong to multiple categories) private final Map> categoryMap = new TreeMap<>(); @@ -139,18 +143,25 @@ public class Catalog { } 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.setEventLoop(eventLoop); + grid.setRowSize(0); grid.setColumnSize(30, 0); categories = buildCategorySelector(eventLoop); ListView scenarios = buildScenarioSelector(eventLoop); + grid.addItem(categories, 0, 0, 1, 1, 0, 0); + grid.addItem(scenarios, 0, 1, 1, 1, 0, 0); + + MenuBarView menuBar = buildMenuBar(eventLoop); + StatusBarView statusBar = buildStatusBar(eventLoop); + + // we use main app view to represent scenario browser + AppView app = new AppView(grid, menuBar, statusBar); + app.setEventLoop(eventLoop); + // handle event when scenario is chosen eventLoop.onDestroy(eventLoop.viewEvents(LISTVIEW_SCENARIO_TYPEREF, scenarios) .subscribe(event -> { @@ -180,14 +191,21 @@ public class Catalog { } )); - // 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); + // we could potentially do keybinding somewhere else + // but at least this shows how to do it in low level. + // essentially we now just handle F10 to toggle + // menubar visibility + // TODO: when we get support for hotkeys we should do + // binding there + eventLoop.onDestroy(eventLoop.keyEvents() + .subscribe(event -> { + log.debug("Raw keyevent {}", event); + if (event.isKey(KeyEvent.Key.f10)) { + app.toggleStatusBarVisibility(); + } + } + )); + return app; }