diff --git a/curses/pom.xml b/curses/pom.xml new file mode 100644 index 000000000..9fbdfd5db --- /dev/null +++ b/curses/pom.xml @@ -0,0 +1,67 @@ + + + + + 4.0.0 + + + org.jline + jline-parent + 3.26.2-SNAPSHOT + + + jline-curses + JLine Curses + + + org.jline.curses + + + + + org.jline + jline-terminal + + + + org.jline + jline-reader + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + true + ${java.release.version} + + -Xlint:all,-options,-processing + + true + + + + + + + diff --git a/curses/src/main/java/org/jline/curses/Component.java b/curses/src/main/java/org/jline/curses/Component.java new file mode 100644 index 000000000..0a0a3f1ed --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Component.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import java.util.EnumSet; + +import org.jline.terminal.MouseEvent; + +public interface Component { + + Position getPosition(); + + void setPosition(Position position); + + Position getScreenPosition(); + + boolean isIn(int x, int y); + + Size getSize(); + + void setSize(Size size); + + Container getParent(); + + Size getPreferredSize(); + + boolean isFocused(); + + boolean isEnabled(); + + void enable(boolean enabled); + + void focus(); + + void draw(Screen screen); + + EnumSet getBehaviors(); + + enum Behavior { + NoFocus, + FullScreen, + NoDecoration, + CloseButton, + ManualLayout, + Popup + } + + void handleMouse(MouseEvent event); + + void handleInput(String input); +} diff --git a/curses/src/main/java/org/jline/curses/Constraint.java b/curses/src/main/java/org/jline/curses/Constraint.java new file mode 100644 index 000000000..220b60e17 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Constraint.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +public interface Constraint {} diff --git a/curses/src/main/java/org/jline/curses/Container.java b/curses/src/main/java/org/jline/curses/Container.java new file mode 100644 index 000000000..357e0a1eb --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Container.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import java.util.Collection; + +public interface Container extends Component { + + /** + * Returns a read-only collection of all contained components. + */ + Collection getComponents(); +} diff --git a/curses/src/main/java/org/jline/curses/Curses.java b/curses/src/main/java/org/jline/curses/Curses.java new file mode 100644 index 000000000..90f4fdccd --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Curses.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import java.util.*; +import java.util.function.Supplier; + +import org.jline.curses.impl.*; +import org.jline.terminal.Terminal; + +public class Curses { + + public enum Border { + Single, + SingleBevel, + Double, + DoubleBevel + } + + public enum Alignment { + Beginning, + Center, + End, + Fill; + } + + public enum Location implements Constraint { + Center, + Top, + Bottom, + Left, + Right + } + + public static class GridConstraint implements Constraint {} + + public static GUI gui(Terminal terminal) { + return new GUIImpl(terminal); + } + + public static WindowBuilder window() { + return new WindowBuilder(); + } + + public static Button button() { + return new Button(); + } + + public static TextArea textArea() { + return new TextArea(); + } + + public static ContainerBuilder border() { + return new ContainerBuilder<>(BorderPanel::new); + } + + public static ContainerBuilder grid() { + return new ContainerBuilder<>(GridPanel::new); + } + + public static MenuBuilder menu() { + return new MenuBuilder(); + } + + public static MenuBuilder menu(SubMenu... subMenus) { + MenuBuilder builder = new MenuBuilder(); + builder.contents.addAll(Arrays.asList(subMenus)); + return builder; + } + + public static MenuBuilder menu(SubMenuBuilder... subMenus) { + MenuBuilder builder = new MenuBuilder(); + for (SubMenuBuilder subMenu : subMenus) { + builder.contents.add(subMenu.build()); + } + return builder; + } + + public static SubMenuBuilder submenu() { + return new SubMenuBuilder(); + } + + public static Box box(String title, Border border, ComponentBuilder component) { + return box(title, border, component.build()); + } + + public static Box box(String title, Border border, Component component) { + return new Box(title, border, component); + } + + public interface ComponentBuilder { + + C build(); + } + + public static class ContainerBuilder implements ComponentBuilder { + + private final Map components = new LinkedHashMap<>(); + private final Supplier supplier; + + ContainerBuilder(Supplier supplier) { + this.supplier = supplier; + } + + public ContainerBuilder add(Component component, C constraint) { + components.put(component, constraint); + return this; + } + + public ContainerBuilder add(ComponentBuilder component, C constraint) { + return add(component.build(), constraint); + } + + public Container build() { + AbstractPanel container = supplier.get(); + components.forEach(container::addComponent); + return container; + } + } + + public static class SubMenuBuilder { + private String name; + private String key; + List contents = new ArrayList<>(); + + public SubMenuBuilder name(String name) { + this.name = name; + return this; + } + + public SubMenuBuilder key(String key) { + this.key = key; + return this; + } + + public SubMenuBuilder item(String name, Runnable action) { + return item().name(name).action(action).add(); + } + + public SubMenuBuilder item(String name, String key, String shortcut, Runnable action) { + return item().name(name).key(key).shortcut(shortcut).action(action).add(); + } + + public MenuItemBuilder item() { + return new MenuItemBuilder(); + } + + public SubMenuBuilder separator() { + contents.add(MenuItem.SEPARATOR); + return this; + } + + public SubMenu build() { + return new SubMenu(name, key, contents); + } + + public class MenuItemBuilder { + + private String name; + private String key; + private String shortcut; + private Runnable action; + + public MenuItemBuilder name(String name) { + this.name = name; + return this; + } + + public MenuItemBuilder key(String key) { + this.key = key; + return this; + } + + public MenuItemBuilder shortcut(String shortcut) { + this.shortcut = shortcut; + return this; + } + + public MenuItemBuilder action(Runnable action) { + this.action = action; + return this; + } + + public MenuItem build() { + MenuItem item = new MenuItem(); + item.setName(this.name); + item.setAction(this.action); + item.setKey(this.key); + item.setShortcut(this.shortcut); + return item; + } + + public SubMenuBuilder add() { + contents.add(build()); + return SubMenuBuilder.this; + } + } + } + + public static class MenuBuilder implements ComponentBuilder { + + List contents = new ArrayList<>(); + + public MenuBuilder submenu(String name, String key, List menu) { + return submenu(new SubMenu(name, key, menu)); + } + + public MenuBuilder submenu(SubMenuBuilder menu) { + return submenu(menu.build()); + } + + public MenuBuilder submenu(SubMenu subMenu) { + contents.add(subMenu); + return this; + } + + public Menu build() { + return new Menu(contents); + } + } + + public static class WindowBuilder { + + private GUI gui; + private String title; + private Component component; + + public WindowBuilder gui(GUI gui) { + this.gui = gui; + return this; + } + + public WindowBuilder title(String title) { + this.title = title; + return this; + } + + public WindowBuilder component(ComponentBuilder component) { + this.component = component.build(); + return this; + } + + public WindowBuilder component(Component component) { + this.component = component; + return this; + } + + public Window build() { + BasicWindow w = new BasicWindow(); + w.setGUI(gui); + w.setTitle(title); + w.setComponent(component); + return w; + } + } +} diff --git a/curses/src/main/java/org/jline/curses/GUI.java b/curses/src/main/java/org/jline/curses/GUI.java new file mode 100644 index 000000000..3a00e2c8f --- /dev/null +++ b/curses/src/main/java/org/jline/curses/GUI.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import org.jline.terminal.Terminal; + +public interface GUI { + + Renderer getRenderer(Class componentClass); + + void setRenderer(Class componentClass, Renderer renderer); + + Theme getTheme(); + + void setTheme(Theme theme); + + void addWindow(Window window); + + void removeWindow(Window window); + + void run(); + + Terminal getTerminal(); +} diff --git a/curses/src/main/java/org/jline/curses/Position.java b/curses/src/main/java/org/jline/curses/Position.java new file mode 100644 index 000000000..16be67f05 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Position.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +public class Position { + + private final int x; + private final int y; + + public Position(int x, int y) { + this.x = x; + this.y = y; + } + + public int x() { + return x; + } + + public int y() { + return y; + } + + @Override + public String toString() { + return "Pos(" + x + ", " + y + ")"; + } +} diff --git a/curses/src/main/java/org/jline/curses/Renderer.java b/curses/src/main/java/org/jline/curses/Renderer.java new file mode 100644 index 000000000..c88d3b6bd --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Renderer.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +public interface Renderer { + + void draw(Screen screen, Component component); + + Size getPreferredSize(Component component); +} diff --git a/curses/src/main/java/org/jline/curses/Screen.java b/curses/src/main/java/org/jline/curses/Screen.java new file mode 100644 index 000000000..8baa71566 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Screen.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStyle; + +public interface Screen { + + void text(int x, int y, AttributedString s); + + void fill(int x, int y, int w, int h, AttributedStyle style); +} diff --git a/curses/src/main/java/org/jline/curses/Size.java b/curses/src/main/java/org/jline/curses/Size.java new file mode 100644 index 000000000..15421545a --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Size.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +public class Size { + + private final int w; + private final int h; + + public Size(int w, int h) { + this.w = w; + this.h = h; + } + + public int w() { + return w; + } + + public int h() { + return h; + } + + @Override + public String toString() { + return "Size(" + w + " x " + h + ")"; + } +} diff --git a/curses/src/main/java/org/jline/curses/Theme.java b/curses/src/main/java/org/jline/curses/Theme.java new file mode 100644 index 000000000..6feb111de --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Theme.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import org.jline.utils.AttributedStyle; + +public interface Theme { + + AttributedStyle getStyle(String spec); + + void box(Screen screen, int x, int y, int w, int h, Curses.Border border, String style); + + void separatorH( + Screen screen, + int x, + int y, + int w, + Curses.Border sepBorder, + Curses.Border boxBorder, + AttributedStyle style); +} diff --git a/curses/src/main/java/org/jline/curses/Window.java b/curses/src/main/java/org/jline/curses/Window.java new file mode 100644 index 000000000..69383bf05 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/Window.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import java.util.Collection; +import java.util.Collections; + +public interface Window extends Container { + + String getTitle(); + + void setTitle(String title); + + Component getComponent(); + + void setComponent(Component component); + + default Collection getComponents() { + return Collections.singleton(getComponent()); + } + + void focus(Component component); + + GUI getGUI(); + + void close(); +} diff --git a/curses/src/main/java/org/jline/curses/impl/AbstractComponent.java b/curses/src/main/java/org/jline/curses/impl/AbstractComponent.java new file mode 100644 index 000000000..409a9bf9f --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/AbstractComponent.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.EnumSet; + +import org.jline.curses.*; +import org.jline.terminal.MouseEvent; + +public abstract class AbstractComponent implements Component { + + private Size size; + private Size preferredSize; + private Position position; + private boolean enabled; + private boolean focused; + private Container parent; + private Renderer renderer; + private Theme theme; + private EnumSet behaviors = EnumSet.noneOf(Behavior.class); + + @Override + public Position getPosition() { + return position; + } + + @Override + public void setPosition(Position position) { + this.position = position; + } + + @Override + public Position getScreenPosition() { + Position p = parent != null ? parent.getScreenPosition() : new Position(0, 0); + return new Position(position.x() + p.x(), position.y() + p.y()); + } + + @Override + public boolean isIn(int x, int y) { + Position p = getScreenPosition(); + Size s = getSize(); + return p.x() <= x && x <= p.x() + s.w() && p.y() <= y && y <= p.y() + s.h(); + } + + @Override + public Size getSize() { + return size; + } + + @Override + public void setSize(Size size) { + this.size = size; + } + + @Override + public Size getPreferredSize() { + if (preferredSize == null) { + return computePreferredSize(); + } + return preferredSize; + } + + public void setPreferredSize(Size preferredSize) { + this.preferredSize = preferredSize; + } + + @Override + public EnumSet getBehaviors() { + return behaviors; + } + + public void setBehaviors(EnumSet behaviors) { + this.behaviors = behaviors; + } + + @Override + public void draw(Screen screen) { + getRenderer().draw(screen, this); + } + + public Renderer getRenderer() { + if (renderer == null) { + return computeRenderer(); + } + return renderer; + } + + public void setRenderer(Renderer renderer) { + this.renderer = renderer; + } + + public Theme getTheme() { + if (theme == null) { + return getWindow().getGUI().getTheme(); + } + return theme; + } + + public void setTheme(Theme theme) { + this.theme = theme; + } + + @Override + public boolean isFocused() { + return focused; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public Container getParent() { + return parent; + } + + public void setParent(Container parent) { + this.parent = parent; + } + + public Window getWindow() { + Container parent = this instanceof Container ? (Container) this : getParent(); + while (parent != null) { + if (parent instanceof Window) { + return (Window) parent; + } else { + parent = parent.getParent(); + } + } + return null; + } + + @Override + public void enable(boolean enabled) { + this.enabled = enabled; + } + + @Override + public void focus() { + if (getWindow() != null) { + getWindow().focus(this); + } + } + + void focused(boolean focused) { + this.focused = focused; + if (focused) { + this.onFocus(); + } else { + this.onUnfocus(); + } + } + + public void onFocus() {} + + public void onUnfocus() {} + + protected Size computePreferredSize() { + return getRenderer().getPreferredSize(this); + } + + protected Renderer computeRenderer() { + Window window = getWindow(); + GUI gui = window != null ? window.getGUI() : null; + Renderer renderer = gui != null ? gui.getRenderer(getClass()) : null; + return renderer != null ? renderer : getDefaultRenderer(); + } + + protected Renderer getDefaultRenderer() { + return new Renderer() { + @Override + public void draw(Screen screen, Component component) { + ((AbstractComponent) component).doDraw(screen); + } + + @Override + public Size getPreferredSize(Component component) { + return ((AbstractComponent) component).doGetPreferredSize(); + } + }; + } + + protected abstract void doDraw(Screen screen); + + protected abstract Size doGetPreferredSize(); + + @Override + public void handleMouse(MouseEvent event) {} + + @Override + public void handleInput(String input) {} +} diff --git a/curses/src/main/java/org/jline/curses/impl/AbstractPanel.java b/curses/src/main/java/org/jline/curses/impl/AbstractPanel.java new file mode 100644 index 000000000..0e952be03 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/AbstractPanel.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.*; + +import org.jline.curses.*; +import org.jline.terminal.MouseEvent; + +public abstract class AbstractPanel extends AbstractComponent implements Container { + + protected final Map components = new LinkedHashMap<>(); + + public void addComponent(Component component, Constraint constraint) { + if (!(component instanceof AbstractComponent)) { + throw new IllegalArgumentException("Components should extend AbstractComponent"); + } + components.put(component, constraint); + ((AbstractComponent) component).setParent(this); + } + + @Override + public Collection getComponents() { + return Collections.unmodifiableSet(components.keySet()); + } + + @Override + public void setSize(Size size) { + super.setSize(size); + layout(); + } + + protected abstract void layout(); + + @Override + protected void doDraw(Screen screen) { + getComponents().forEach(c -> c.draw(screen)); + } + + @Override + public void handleMouse(MouseEvent event) { + for (Component component : components.keySet()) { + if (component.isIn(event.getX(), event.getY())) { + component.handleMouse(event); + return; + } + } + super.handleMouse(event); + } + + @Override + public void handleInput(String input) { + super.handleInput(input); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/AbstractWindow.java b/curses/src/main/java/org/jline/curses/impl/AbstractWindow.java new file mode 100644 index 000000000..62855566e --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/AbstractWindow.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.EnumSet; + +import org.jline.curses.*; +import org.jline.terminal.MouseEvent; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStyle; + +public abstract class AbstractWindow extends AbstractComponent implements Window { + + private String title; + private Component component; + private GUI gui; + private AbstractComponent focused; + + public AbstractWindow() { + this(null, null); + } + + public AbstractWindow(String title) { + this(title, null); + } + + public AbstractWindow(String title, Component component) { + this.title = title; + this.component = component; + this.setBehaviors(EnumSet.of(Behavior.CloseButton)); + } + + @Override + public String getTitle() { + return title; + } + + @Override + public void setTitle(String title) { + this.title = title; + } + + @Override + public Component getComponent() { + return component; + } + + @Override + public void setComponent(Component component) { + ((AbstractComponent) component).setParent(this); + this.component = component; + } + + @Override + public GUI getGUI() { + return gui; + } + + public void setGUI(GUI gui) { + this.gui = gui; + } + + @Override + public void setSize(Size size) { + super.setSize(size); + if (component != null) { + component.setPosition(getRenderer().getComponentOffset()); + component.setSize(getRenderer().getComponentSize(size)); + } + } + + @Override + public Size getPreferredSize() { + return component != null ? component.getPreferredSize() : new Size(0, 0); + } + + @Override + public void focus(Component component) { + AbstractComponent c = (AbstractComponent) component; + if (c != null && c.getWindow() != this) { + throw new IllegalStateException(); + } + if (focused != c) { + if (focused != null) { + focused.focused(false); + } + focused = c; + if (focused != null) { + focused.focused(true); + } + } + } + + @Override + public WindowRenderer getRenderer() { + return (WindowRenderer) super.getRenderer(); + } + + @Override + public void setRenderer(Renderer renderer) { + super.setRenderer((WindowRenderer) renderer); + } + + @Override + protected WindowRenderer getDefaultRenderer() { + return new WindowRenderer() { + @Override + public void draw(Screen screen, Component window) { + ((AbstractWindow) window).doDraw(screen); + } + + @Override + public Size getPreferredSize(Component window) { + return ((AbstractWindow) window).doGetPreferredSize(); + } + + @Override + public Position getComponentOffset() { + return new Position(1, 1); + } + + @Override + public Size getComponentSize(Size box) { + return new Size(Math.max(0, box.w() - 2), Math.max(0, box.h() - 2)); + } + }; + } + + @Override + public void handleInput(String input) { + if (input.contains("q")) { + close(); + } + } + + @Override + public void handleMouse(MouseEvent event) { + if (component != null && component.isIn(event.getX(), event.getY())) { + component.handleMouse(event); + return; + } + if (getBehaviors().contains(Behavior.CloseButton) && !getBehaviors().contains(Behavior.NoDecoration)) { + Position pos = getScreenPosition(); + if (event.getX() == pos.x() + getSize().w() - 2 && event.getY() == pos.y()) { + close(); + } + } + } + + @Override + public void close() { + GUI gui = getGUI(); + if (gui != null) { + gui.removeWindow(this); + } + } + + @Override + protected void doDraw(Screen screen) { + Position pos = getScreenPosition(); + if (getBehaviors().contains(Behavior.NoDecoration)) { + AttributedStyle st = getTheme().getStyle(".window.border"); + screen.fill(pos.x(), pos.y(), getSize().w(), getSize().h(), st); + } else { + screen.fill( + pos.x() + 2, + pos.y() + 1, + getSize().w(), + getSize().h(), + getTheme().getStyle(".window.shadow")); + getTheme() + .box( + screen, + pos.x(), + pos.y(), + getSize().w(), + getSize().h(), + Curses.Border.Double, + ".window.border"); + if (getBehaviors().contains(Behavior.CloseButton)) { + screen.text( + pos.x() + getSize().w() - 2, + pos.y(), + new AttributedString("x", getTheme().getStyle(".window.close"))); + } + if (title != null) { + screen.text( + pos.x() + 3, + pos.y(), + new AttributedString(title, getTheme().getStyle(".window.title"))); + } + if (component != null) { + component.draw(screen); + } + } + } + + @Override + protected Size doGetPreferredSize() { + Size sz = getComponent().getPreferredSize(); + if (getBehaviors().contains(Behavior.NoDecoration)) { + return sz; + } else { + return new Size(sz.w() + 2, sz.h() + 2); + } + } + + public interface WindowRenderer extends Renderer { + Position getComponentOffset(); + + Size getComponentSize(Size box); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/BasicWindow.java b/curses/src/main/java/org/jline/curses/impl/BasicWindow.java new file mode 100644 index 000000000..fface6868 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/BasicWindow.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +public class BasicWindow extends AbstractWindow { + + public BasicWindow() {} +} diff --git a/curses/src/main/java/org/jline/curses/impl/BorderPanel.java b/curses/src/main/java/org/jline/curses/impl/BorderPanel.java new file mode 100644 index 000000000..5f7d37aeb --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/BorderPanel.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.EnumMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.jline.curses.*; +import org.jline.curses.Curses.Location; + +public class BorderPanel extends AbstractPanel { + + @Override + public void addComponent(Component component, Constraint constraint) { + if (!(constraint instanceof Location)) { + throw new IllegalArgumentException("Constraint should be a Location: " + constraint); + } + if (components.containsValue(constraint)) { + throw new IllegalArgumentException("Two components have the same location: " + constraint); + } + super.addComponent(component, constraint); + } + + @Override + protected Size doGetPreferredSize() { + Map w = new EnumMap<>(Location.class); + Map h = new EnumMap<>(Location.class); + // Compute preferred heights and widths of components + preferred(w, h); + // Width + int pw = max( + w.get(Location.Top), + w.get(Location.Left) + w.get(Location.Center) + w.get(Location.Right), + w.get(Location.Bottom)); + // Height + int ph = h.get(Location.Top) + + max(h.get(Location.Left), h.get(Location.Center), h.get(Location.Right)) + + h.get(Location.Bottom); + return new Size(pw, ph); + } + + @Override + protected void layout() { + Size size = getSize(); + + Map x = new EnumMap<>(Location.class); + Map y = new EnumMap<>(Location.class); + Map w = new EnumMap<>(Location.class); + Map h = new EnumMap<>(Location.class); + // Compute preferred heights and widths + preferred(w, h); + // Arrange + fit(h, size.h(), Location.Center, Location.Top, Location.Bottom); + fit(w, size.w(), Location.Center, Location.Left, Location.Right); + w.put(Location.Top, size.w()); + w.put(Location.Bottom, size.w()); + h.put(Location.Left, h.get(Location.Center)); + h.put(Location.Right, h.get(Location.Center)); + pos(x, w, Location.Left, Location.Center, Location.Right); + pos(y, h, Location.Top, Location.Center, Location.Bottom); + x.put(Location.Top, 0); + x.put(Location.Bottom, 0); + y.put(Location.Left, y.get(Location.Center)); + y.put(Location.Right, y.get(Location.Center)); + // Assign + for (Map.Entry entry : components.entrySet()) { + Component c = entry.getKey(); + Location l = (Location) entry.getValue(); + c.setPosition(new Position(x.get(l), y.get(l))); + c.setSize(new Size(w.get(l), h.get(l))); + } + } + + private void pos(Map p, Map s, Location... locs) { + int c = 0; + for (Location loc : locs) { + p.put(loc, c); + c += s.getOrDefault(loc, 0); + } + } + + private void fit(Map h, int max, Location... locs) { + int diff = Stream.of(locs) + .map(l -> h.getOrDefault(l, 0)) + .mapToInt(Integer::intValue) + .sum() + - max; + if (diff < 0) { + h.put(locs[0], h.get(locs[0]) - diff); + } else { + for (int idx = locs.length - 1; diff > 0; idx--) { + int l = h.get(locs[idx]); + int nb = Math.min(diff, l); + h.put(locs[idx], l - nb); + diff -= nb; + } + } + } + + private void preferred(Map w, Map h) { + for (Location l : Location.values()) { + w.put(l, 0); + h.put(l, 0); + } + for (Map.Entry e : components.entrySet()) { + Location l = (Location) e.getValue(); + Size s = e.getKey().getPreferredSize(); + w.put(l, s.w()); + h.put(l, s.h()); + } + } + + private static int max(int i0, int i1, int i2) { + return Math.max(i0, Math.max(i1, i2)); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/Box.java b/curses/src/main/java/org/jline/curses/impl/Box.java new file mode 100644 index 000000000..364bb6096 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/Box.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.Collection; +import java.util.Collections; + +import org.jline.curses.*; + +public class Box extends AbstractComponent implements Container { + + private final String title; + private final Curses.Border border; + private final Component component; + + public Box(String title, Curses.Border border, Component component) { + this.title = title; + this.border = border; + this.component = component; + ((AbstractComponent) component).setParent(this); + } + + @Override + public BoxRenderer getRenderer() { + return (BoxRenderer) super.getRenderer(); + } + + @Override + public void setRenderer(Renderer renderer) { + super.setRenderer((BoxRenderer) renderer); + } + + public String getTitle() { + return title; + } + + public Curses.Border getBorder() { + return border; + } + + public Component getComponent() { + return component; + } + + @Override + public void setSize(Size size) { + super.setSize(size); + component.setPosition(getRenderer().getComponentOffset()); + component.setSize(getRenderer().getComponentSize(size)); + } + + @Override + public Collection getComponents() { + return Collections.singleton(component); + } + + @Override + protected void doDraw(Screen screen) {} + + @Override + protected Size doGetPreferredSize() { + Size sz = getComponent().getPreferredSize(); + if (getBehaviors().contains(Behavior.NoDecoration)) { + return sz; + } else { + return new Size(sz.w() + 2, sz.h() + 2); + } + } + + @Override + protected BoxRenderer getDefaultRenderer() { + return new BoxRenderer() { + @Override + public void draw(Screen screen, Component box) { + ((Box) box).doDraw(screen); + } + + @Override + public Size getPreferredSize(Component box) { + return ((Box) box).doGetPreferredSize(); + } + + @Override + public Position getComponentOffset() { + return new Position(1, 1); + } + + @Override + public Size getComponentSize(Size box) { + return new Size(Math.max(0, box.w() - 2), Math.max(0, box.h() - 2)); + } + }; + } + + public interface BoxRenderer extends Renderer { + Position getComponentOffset(); + + Size getComponentSize(Size box); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/Button.java b/curses/src/main/java/org/jline/curses/impl/Button.java new file mode 100644 index 000000000..680cc2cd4 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/Button.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import org.jline.curses.Screen; +import org.jline.curses.Size; + +public class Button extends AbstractComponent { + + @Override + protected void doDraw(Screen screen) {} + + @Override + protected Size doGetPreferredSize() { + return null; + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/DefaultTheme.java b/curses/src/main/java/org/jline/curses/impl/DefaultTheme.java new file mode 100644 index 000000000..bb07cc709 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/DefaultTheme.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.HashMap; +import java.util.Map; + +import org.jline.curses.Curses; +import org.jline.curses.Screen; +import org.jline.curses.Theme; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.jline.utils.StyleResolver; + +public class DefaultTheme implements Theme { + + private final Map styles = new HashMap<>(); + private final StyleResolver resolver = new StyleResolver(styles::get); + private final Map boxChars = new HashMap<>(); + + private static final int TOP_LEFT = 0; + private static final int TOP = 1; + private static final int TOP_RIGHT = 2; + private static final int LEFT = 3; + private static final int CENTER = 4; + private static final int RIGHT = 5; + private static final int BOTTOM_LEFT = 6; + private static final int BOTTOM = 7; + private static final int BOTTOM_RIGHT = 8; + + public DefaultTheme() { + styles.put("menu.text.normal", "fg:!white,bg:cyan"); + styles.put("menu.key.normal", "fg:!yellow,bg:cyan"); + styles.put("menu.text.selected", "fg:!white,bg:black"); + styles.put("menu.key.selected", "fg:!yellow,bg:black"); + styles.put("menu.border", "fg:!white,bg:cyan"); + styles.put("background", "bg:blue,fg:black"); + styles.put("window.border", "bg:white,fg:black"); + styles.put("window.border.back", "bg:white,fg:black"); + styles.put("window.border.light", "bg:white,fg:white"); + styles.put("window.title", "bg:white,fg:black"); + styles.put("window.shadow", "bg:black,fg:black"); + styles.put("window.close", "bg:white,fg:black"); + styles.put("box.chars.double", "╔═╗║ ║╚═╝"); + styles.put("box.chars.single", "┌─┐│ │└─┘"); + styles.put("sep.chars.horz.double.double", "╠═╣"); + styles.put("sep.chars.horz.double.single", "╞═╡"); + styles.put("sep.chars.horz.single.single", "├─┤"); + styles.put("sep.chars.horz.single.double", "╟─╢"); + } + + @Override + public AttributedStyle getStyle(String spec) { + return resolver.resolve(spec); + } + + @Override + public void separatorH( + Screen screen, + int x, + int y, + int w, + Curses.Border sepBorder, + Curses.Border boxBorder, + AttributedStyle style) { + String chars = styles.get("sep.chars.horz." + sord(sepBorder) + "." + sord(boxBorder)); + AttributedString sb = createBoxString(chars, w, style, 0, style, 1, style, 2); + screen.text(x, y, sb); + } + + String sord(Curses.Border border) { + switch (border) { + case Double: + case DoubleBevel: + return "double"; + default: + return "single"; + } + } + + @SuppressWarnings("fallthrough") + @Override + public void box(Screen screen, int x, int y, int w, int h, Curses.Border border, String style) { + if (w <= 0 || h <= 0) { + return; + } + AttributedStyle nst = getStyle(style); + AttributedStyle bst = getStyle(style + ".back"); + AttributedStyle hst = nst; + String chars; + switch (border) { + case DoubleBevel: + hst = getStyle(style + ".light"); + case Double: + chars = styles.get("box.chars.double"); + break; + case SingleBevel: + hst = getStyle(style + ".light"); + case Single: + chars = styles.get("box.chars.single"); + break; + default: + throw new IllegalStateException(); + } + AttributedString top = createBoxString(chars, w, hst, TOP_LEFT, hst, TOP, nst, TOP_RIGHT); + AttributedString mid = createBoxString(chars, w, hst, LEFT, bst, CENTER, nst, RIGHT); + AttributedString bot = createBoxString(chars, w, hst, BOTTOM_LEFT, nst, BOTTOM, nst, BOTTOM_RIGHT); + screen.text(x, y, top); + for (int j = y + 1; j < y + h - 1; j++) { + screen.text(x, j, mid); + } + screen.text(x, y + h - 1, bot); + } + + private AttributedString createBoxString( + String chars, int w, AttributedStyle s0, int c0, AttributedStyle s1, int c1, AttributedStyle s2, int c2) { + AttributedStringBuilder sb = new AttributedStringBuilder(w); + sb.style(s0); + sb.append(chars.charAt(c0)); + sb.style(s1); + for (int i = 0; i < w - 2; i++) { + sb.append(chars.charAt(c1)); + } + sb.style(s2); + sb.append(chars.charAt(c2)); + return sb.toAttributedString(); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/GUIImpl.java b/curses/src/main/java/org/jline/curses/impl/GUIImpl.java new file mode 100644 index 000000000..73eec3e30 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/GUIImpl.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.io.IOException; +import java.util.*; + +import org.jline.curses.*; +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Attributes; +import org.jline.terminal.MouseEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedStyle; +import org.jline.utils.Display; +import org.jline.utils.InfoCmp; + +public class GUIImpl implements GUI { + + private final Terminal terminal; + private final Deque windows = new ArrayDeque<>(); + private Window activeWindow; + private final AbstractWindow background; + private Size size; + private Display display; + private final Map, Renderer> renderers = new HashMap<>(); + private Theme theme = new DefaultTheme(); + + public GUIImpl(Terminal terminal) { + this.terminal = terminal; + this.background = new BasicWindow() { + @Override + protected void doDraw(Screen screen) { + AttributedStyle st = getTheme().getStyle(".background"); + screen.fill( + getPosition().x(), + getPosition().y(), + getSize().w(), + getSize().h(), + st); + } + }; + this.background.setGUI(this); + this.background.setBehaviors(EnumSet.of(Component.Behavior.NoDecoration, Component.Behavior.FullScreen)); + } + + @Override + public Terminal getTerminal() { + return terminal; + } + + @Override + public Renderer getRenderer(Class clazz) { + return renderers.get(clazz); + } + + @Override + public void setRenderer(Class clazz, Renderer renderer) { + this.renderers.put(clazz, renderer); + } + + @Override + public Theme getTheme() { + return theme; + } + + @Override + public void setTheme(Theme theme) { + this.theme = theme; + } + + @Override + public void addWindow(Window window) { + if (window.getGUI() != null) { + window.getGUI().removeWindow(window); + } + windows.add(window); + ((AbstractWindow) window).setGUI(this); + if (!window.getBehaviors().contains(Window.Behavior.NoFocus)) { + activeWindow = window; + } + // todo: refresh + } + + @Override + public void removeWindow(Window window) { + if (windows.remove(window)) { + ((AbstractWindow) window).setGUI(null); + if (activeWindow == window) { + activeWindow = null; + for (Window w : windows) { + if (!w.getBehaviors().contains(Window.Behavior.NoFocus)) { + activeWindow = w; // no break, the last will be the one + } + } + } + // todo: refresh + } + } + + @Override + public void run() { + BindingReader bindingReader = new BindingReader(terminal.reader()); + KeyMap map = new KeyMap<>(); + map.setNomatch(Event.Key); + map.setUnicode(Event.Key); + map.bind(Event.Mouse, KeyMap.key(terminal, InfoCmp.Capability.key_mouse)); + + Attributes attributes = terminal.getAttributes(); + Attributes newAttr = new Attributes(attributes); + newAttr.setLocalFlags( + EnumSet.of(Attributes.LocalFlag.ICANON, Attributes.LocalFlag.ECHO, Attributes.LocalFlag.IEXTEN), false); + newAttr.setInputFlags( + EnumSet.of(Attributes.InputFlag.IXON, Attributes.InputFlag.ICRNL, Attributes.InputFlag.INLCR), false); + newAttr.setControlChar(Attributes.ControlChar.VMIN, 0); + newAttr.setControlChar(Attributes.ControlChar.VTIME, 1); + newAttr.setControlChar(Attributes.ControlChar.VINTR, 0); + terminal.setAttributes(newAttr); + Terminal.SignalHandler prevHandler = terminal.handle(Terminal.Signal.WINCH, this::handle); + terminal.puts(InfoCmp.Capability.enter_ca_mode); + terminal.puts(InfoCmp.Capability.keypad_xmit); + terminal.trackMouse(Terminal.MouseTracking.Button); + terminal.puts(InfoCmp.Capability.cursor_invisible); + display = new Display(terminal, true); + + try { + onResize(); + while (!windows.isEmpty()) { + Event event = bindingReader.readBinding(map); + switch (event) { + case Key: + handleInput(bindingReader.getLastBinding()); + break; + case Mouse: + handleMouse(terminal.readMouseEvent(bindingReader::readCharacter)); + break; + } + redraw(); + } + try { + while (terminal.reader().read(1) > 0) + ; + } catch (IOException e) { + // ignore + } + } finally { + terminal.puts(InfoCmp.Capability.cursor_visible); + terminal.trackMouse(Terminal.MouseTracking.Off); + terminal.puts(InfoCmp.Capability.exit_ca_mode); + terminal.puts(InfoCmp.Capability.keypad_local); + terminal.flush(); + terminal.setAttributes(attributes); + terminal.handle(Terminal.Signal.WINCH, prevHandler); + } + } + + private void handle(Terminal.Signal signal) { + if (signal == Terminal.Signal.WINCH) { + onResize(); + } + } + + private void onResize() { + org.jline.terminal.Size sz = terminal.getSize(); + size = new Size(sz.getColumns(), sz.getRows()); + display.resize(sz.getRows(), sz.getColumns()); + background.setPosition(new Position(0, 0)); + background.setSize(size); + for (Window window : windows) { + if (!window.getBehaviors().contains(Component.Behavior.ManualLayout)) { + window.setPosition(new Position(size.w() / 4, size.h() / 4)); + window.setSize(new Size(size.w() / 2, size.h() / 2)); + } + } + redraw(); + } + + enum Event { + Key, + Mouse + } + + protected void handleInput(String input) { + if (activeWindow != null) { + activeWindow.handleInput(input); + } else { + background.handleInput(input); + } + } + + protected void handleMouse(MouseEvent event) { + int x = event.getX(); + int y = event.getY(); + Window window = null; + if (activeWindow != null && activeWindow.getBehaviors().contains(Component.Behavior.Popup)) { + window = activeWindow; + } else { + for (Iterator it = windows.descendingIterator(); it.hasNext(); ) { + Window w = it.next(); + if (w.isIn(x, y)) { + window = w; + break; + } + } + } + if (window == null) { + window = background; + } + window.handleMouse(event); + } + + protected void redraw() { + VirtualScreen screen = new VirtualScreen(size.w(), size.h()); + background.draw(screen); + windows.forEach(w -> w.draw(screen)); + display.update(screen.lines(), -1, true); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/GridPanel.java b/curses/src/main/java/org/jline/curses/impl/GridPanel.java new file mode 100644 index 000000000..37d16b793 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/GridPanel.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import org.jline.curses.Size; + +public class GridPanel extends AbstractPanel { + + @Override + protected void layout() {} + + @Override + protected Size doGetPreferredSize() { + return null; + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/Menu.java b/curses/src/main/java/org/jline/curses/impl/Menu.java new file mode 100644 index 000000000..8f4aaee7e --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/Menu.java @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jline.curses.Curses; +import org.jline.curses.Position; +import org.jline.curses.Screen; +import org.jline.curses.Size; +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.MouseEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.jline.utils.InfoCmp; +import org.jline.utils.NonBlockingReader; + +public class Menu extends AbstractComponent { + + enum Action { + Left, + Right, + Up, + Down, + Execute, + Close + } + + private final List contents; + private SubMenu selected; + private KeyMap keyMap; + private KeyMap global; + private final BindingReader bindingReader = new BindingReader(new NonBlockingReader() { + @Override + protected int read(long timeout, boolean isPeek) { + return -1; + } + + @Override + public int readBuffered(char[] b) { + return -1; + } + + @Override + public int readBuffered(char[] b, int off, int len, long timeout) { + return -1; + } + + @Override + public void close() {} + }); + private final Map windows = new HashMap<>(); + + public Menu(List contents) { + this.contents = contents; + for (SubMenu s : contents) { + this.windows.put(s, new MenuWindow(s)); + } + } + + public List getContents() { + return contents; + } + + @Override + protected void doDraw(Screen screen) { + AttributedStyle tn = getTheme().getStyle(".menu.text.normal"); + AttributedStyle kn = getTheme().getStyle(".menu.key.normal"); + AttributedStyle ts = getTheme().getStyle(".menu.text.selected"); + AttributedStyle ks = getTheme().getStyle(".menu.key.selected"); + int x = getScreenPosition().x(); + int y = getScreenPosition().y(); + int w = getSize().w(); + AttributedStringBuilder sb = new AttributedStringBuilder(); + for (SubMenu c : getContents()) { + boolean selected = c == this.selected; + String n = c.getName(); + String k = c.getKey(); + sb.style(tn); + sb.append(" "); + sb.style(selected ? ts : tn); + sb.append(" "); + int ki = k != null ? n.indexOf(k) : -1; + if (ki >= 0) { + sb.style(selected ? ts : tn); + sb.append(n, 0, ki); + sb.style(selected ? ks : kn); + sb.append(n, ki, ki + k.length()); + sb.style(selected ? ts : tn); + sb.append(n, ki + k.length(), n.length()); + } else { + sb.style(selected ? ts : tn); + sb.append(n); + } + sb.style(selected ? ts : tn); + sb.append(" "); + sb.style(tn); + sb.append(" "); + } + sb.style(tn); + while (sb.length() < w) { + sb.append(' '); + } + screen.text(x, y, sb.toAttributedString()); + } + + @Override + protected Size doGetPreferredSize() { + int size = -1; + for (SubMenu mc : getContents()) { + size += mc.getName().length() + 5; + } + return new Size(size, 1); + } + + @Override + public void handleMouse(MouseEvent event) { + int dx = event.getX() - getScreenPosition().x(); + SubMenu sel = null; + for (SubMenu mc : getContents()) { + int l = 4 + mc.getName().length(); + if (dx < l) { + sel = mc; + break; + } + dx -= l + 1; + } + select(sel); + } + + @Override + public void handleInput(String input) { + if (keyMap == null) { + Terminal terminal = getWindow().getGUI().getTerminal(); + keyMap = new KeyMap<>(); + keyMap.bind(Action.Up, KeyMap.key(terminal, InfoCmp.Capability.key_up)); + keyMap.bind(Action.Down, KeyMap.key(terminal, InfoCmp.Capability.key_down)); + keyMap.bind(Action.Left, KeyMap.key(terminal, InfoCmp.Capability.key_left)); + keyMap.bind(Action.Right, KeyMap.key(terminal, InfoCmp.Capability.key_right)); + keyMap.bind(Action.Execute, KeyMap.key(terminal, InfoCmp.Capability.key_enter), " ", "\n", "\r"); + keyMap.bind(Action.Close, KeyMap.esc()); + global = new KeyMap<>(); + for (SubMenu subMenu : contents) { + for (MenuItem item : subMenu.getContents()) { + String s = item.getShortcut(); + if (s != null) { + global.bind(item, KeyMap.translate(s)); + } + } + } + } + bindingReader.runMacro(input); + Object binding = bindingReader.readBinding(keyMap, windows.get(selected).keyMap); + if (binding instanceof Action) { + Action action = (Action) binding; + switch (action) { + case Left: + select(contents.get((contents.indexOf(selected) + contents.size() - 1) % contents.size())); + break; + case Right: + select(contents.get((contents.indexOf(selected) + contents.size() + 1) % contents.size())); + break; + case Up: + if (selected != null) { + windows.get(selected).up(); + } + break; + case Down: + if (selected != null) { + windows.get(selected).down(); + } + break; + case Close: + if (selected != null) { + windows.get(selected).close(); + } + break; + case Execute: + if (selected != null) { + closeAndExecute(windows.get(selected).selected); + } + break; + } + } else if (binding instanceof MenuItem) { + closeAndExecute((MenuItem) binding); + } + } + + private void closeAndExecute(MenuItem item) { + MenuWindow w = windows.get(selected); + w.close(); + if (item.getAction() != null) { + item.getAction().run(); + } + } + + private void select(SubMenu s) { + if (s != selected) { + if (selected != null) { + windows.get(selected).close(); + } + selected = s; + if (selected != null) { + getWindow().getGUI().addWindow(windows.get(selected)); + } + } + } + + @Override + public void setPosition(Position position) { + super.setPosition(position); + Position p = getScreenPosition(); + int x = p.x(); + for (SubMenu mc : getContents()) { + MenuWindow w = windows.get(mc); + w.setPosition(new Position(x, p.y() + 1)); + w.setSize(w.getPreferredSize()); + int l = 4 + mc.getName().length(); + x += l + 1; + } + } + + class MenuWindow extends AbstractWindow { + + private final SubMenu subMenu; + private final KeyMap keyMap; + private MenuItem selected; + + public MenuWindow(SubMenu subMenu) { + this.subMenu = subMenu; + this.selected = subMenu.getContents().stream() + .filter(c -> c != MenuItem.SEPARATOR) + .findFirst() + .orElse(null); + setBehaviors(EnumSet.of(Behavior.NoDecoration, Behavior.Popup, Behavior.ManualLayout)); + this.keyMap = new KeyMap<>(); + for (MenuItem item : subMenu.getContents()) { + if (item.getKey() != null) { + keyMap.bind(item, item.getKey().toLowerCase()); + } + } + } + + @Override + protected void doDraw(Screen screen) { + AttributedStyle tn = getTheme().getStyle(".menu.text.normal"); + AttributedStyle kn = getTheme().getStyle(".menu.key.normal"); + AttributedStyle ts = getTheme().getStyle(".menu.text.selected"); + AttributedStyle ks = getTheme().getStyle(".menu.key.selected"); + Position p = getScreenPosition(); + Size s = getSize(); + if (s.h() <= 0 || s.w() <= 0) { + return; + } + getTheme().box(screen, p.x(), p.y(), s.w(), s.h(), Curses.Border.Single, ".menu.border"); + int y = p.y() + 1; + int ws = 0; + for (MenuItem mi : subMenu.getContents()) { + if (mi.getShortcut() != null) { + ws = Math.max(ws, mi.getShortcut().length()); + } + } + for (MenuItem c : subMenu.getContents()) { + if (c == MenuItem.SEPARATOR) { + getTheme() + .separatorH( + screen, + p.x(), + y++, + s.w(), + Curses.Border.Single, + Curses.Border.Single, + getTheme().getStyle(".menu.border")); + continue; + } + boolean selected = c == this.selected; + String n = c.getName(); + String k = c.getKey(); + String t = c.getShortcut(); + AttributedStringBuilder sb = new AttributedStringBuilder(s.w()); + sb.style(selected ? ts : tn); + sb.append(" "); + int ki = k != null ? n.indexOf(k) : -1; + if (ki >= 0) { + sb.style(selected ? ts : tn); + sb.append(n, 0, ki); + sb.style(selected ? ks : kn); + sb.append(n, ki, ki + k.length()); + sb.style(selected ? ts : tn); + sb.append(n, ki + k.length(), n.length()); + } else { + sb.style(selected ? ts : tn); + sb.append(n); + } + sb.style(selected ? ts : tn); + if (ws > 0) { + while (sb.length() < s.w() - 2 - ws) { + sb.append(" "); + } + } + if (t != null) { + sb.append(t); + } + sb.style(selected ? ts : tn); + while (sb.length() < s.w() - 2) { + sb.append(" "); + } + screen.text(p.x() + 1, y++, sb.toAttributedString()); + } + } + + @Override + public void handleInput(String input) { + Menu.this.handleInput(input); + } + + void up() { + move(-1); + } + + void down() { + move(+1); + } + + void move(int dir) { + List contents = subMenu.getContents(); + int idx = contents.indexOf(selected); + for (; ; ) { + idx = (idx + contents.size() + dir) % contents.size(); + if (contents.get(idx) != MenuItem.SEPARATOR) { + break; + } + } + selected = contents.get(idx); + } + + @Override + public void handleMouse(MouseEvent event) { + if (event.getType() == MouseEvent.Type.Pressed && !isIn(event.getX(), event.getY())) { + close(); + } else { + Position p = getScreenPosition(); + Size s = getSize(); + int x = p.x() + 1; + int w = s.w() - 2; + int y = p.y() + 1; + if (x <= event.getX() && event.getX() <= x + w) { + MenuItem clicked = null; + for (MenuItem item : subMenu.getContents()) { + if (event.getY() == y) { + clicked = item; + break; + } + y++; + } + if (clicked != null && clicked != MenuItem.SEPARATOR) { + closeAndExecute(clicked); + } + } + super.handleMouse(event); + } + } + + @Override + public Size getPreferredSize() { + int wn = 0; + int ws = 0; + int h = 0; + for (MenuItem mi : subMenu.getContents()) { + h++; + if (mi == MenuItem.SEPARATOR) { + continue; + } + wn = Math.max(wn, mi.getName().length()); + if (mi.getShortcut() != null) { + ws = Math.max(ws, mi.getShortcut().length()); + } + } + return new Size(ws > 0 ? (1 + 1 + wn + 2 + ws + 1) : (1 + 1 + wn + 2), h + 2); + } + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/MenuItem.java b/curses/src/main/java/org/jline/curses/impl/MenuItem.java new file mode 100644 index 000000000..2f65bdc4e --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/MenuItem.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +public class MenuItem { + + public static final MenuItem SEPARATOR = new MenuItem(); + + private String name; + private String key; + private String shortcut; + private Runnable action; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getShortcut() { + return shortcut; + } + + public void setShortcut(String shortcut) { + this.shortcut = shortcut; + } + + public Runnable getAction() { + return action; + } + + public void setAction(Runnable action) { + this.action = action; + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/SubMenu.java b/curses/src/main/java/org/jline/curses/impl/SubMenu.java new file mode 100644 index 000000000..7b91a2b10 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/SubMenu.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.List; + +public class SubMenu { + + private final String name; + private final String key; + private final List contents; + + public SubMenu(String name, String key, List contents) { + this.name = name; + this.key = key; + this.contents = contents; + } + + public String getName() { + return name; + } + + public String getKey() { + return key; + } + + public List getContents() { + return contents; + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/TextArea.java b/curses/src/main/java/org/jline/curses/impl/TextArea.java new file mode 100644 index 000000000..3d2c51885 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/TextArea.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import org.jline.curses.Screen; +import org.jline.curses.Size; + +public class TextArea extends AbstractComponent { + + @Override + protected void doDraw(Screen screen) { + // TODO + } + + @Override + protected Size doGetPreferredSize() { + return new Size(3, 3); + } +} diff --git a/curses/src/main/java/org/jline/curses/impl/VirtualScreen.java b/curses/src/main/java/org/jline/curses/impl/VirtualScreen.java new file mode 100644 index 000000000..505144847 --- /dev/null +++ b/curses/src/main/java/org/jline/curses/impl/VirtualScreen.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.jline.curses.Screen; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; + +public class VirtualScreen implements Screen { + + private final int width; + private final int height; + private final char[] chars; + private final long[] styles; + + public VirtualScreen(int width, int height) { + this.width = width; + this.height = height; + this.chars = new char[width * height]; + this.styles = new long[width * height]; + } + + @Override + public void text(int x, int y, AttributedString s) { + int p = y * width + x; + for (int i = 0; i < s.length(); i++, p++) { + chars[p] = s.charAt(i); + styles[p] = s.styleAt(i).getStyle(); + } + } + + @Override + public void fill(int x, int y, int w, int h, AttributedStyle style) { + long s = style.getStyle(); + for (int j = 0; j < h; j++) { + int p = (y + j) * width + x; + for (int i = 0; i < w; i++, p++) { + chars[p] = ' '; + styles[p] = s; + } + } + } + + public List lines() { + List lines = new ArrayList<>(height); + AttributedStringBuilder sb = new AttributedStringBuilder(width); + int p = 0; + for (int j = 0; j < height; j++) { + sb.setLength(0); + for (int i = 0; i < width; i++) { + sb.style(new AttributedStyle(styles[p], 0xFFFFFFFF)); + sb.append(chars[p]); + p++; + } + lines.add(sb.toAttributedString()); + } + return lines; + } +} diff --git a/curses/src/test/java/org/jline/curses/CursesTest.java b/curses/src/test/java/org/jline/curses/CursesTest.java new file mode 100644 index 000000000..e0717b94e --- /dev/null +++ b/curses/src/test/java/org/jline/curses/CursesTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses; + +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.utils.InfoCmp; + +import static org.jline.curses.Curses.*; + +public class CursesTest { + + Terminal terminal; + Component menu, text; + Window window; + GUI gui; + + public static void main(String[] args) throws Exception { + new CursesTest().run(); + } + + public void run() throws Exception { + terminal = TerminalBuilder.terminal(); + + window = window().title("mytitle") + .component(border().add( + menu = menu( + submenu() + .name("File") + .key("F") + .item("View", "V", "F3", this::view) + .separator() + .item("Select group", "g", "C-x C-s", this::selectGroup), + submenu() + .name("Command") + .key("C") + .item("User menu", this::userMenu)) + .build(), + Location.Top) + .add(box("Text", Border.Double, text = textArea()), Location.Center)) + .build(); + + gui = gui(terminal); + gui.addWindow(window); + gui.run(); + } + + private void view() { + terminal.puts(InfoCmp.Capability.clear_screen); + System.out.println("view"); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + private void userMenu() { + terminal.puts(InfoCmp.Capability.clear_screen); + System.out.println("userMenu"); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + private void selectGroup() { + terminal.puts(InfoCmp.Capability.clear_screen); + System.out.println("selectGroup"); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } +} diff --git a/curses/src/test/java/org/jline/curses/impl/BorderPanelTest.java b/curses/src/test/java/org/jline/curses/impl/BorderPanelTest.java new file mode 100644 index 000000000..f01449b0d --- /dev/null +++ b/curses/src/test/java/org/jline/curses/impl/BorderPanelTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.curses.impl; + +import org.jline.curses.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class BorderPanelTest { + + @Test + public void testBorderExactSize() { + Container panel = Curses.border() + .add(new TestComponent("t", new Size(16, 2)), Curses.Location.Top) + .add(new TestComponent("c", new Size(6, 6)), Curses.Location.Center) + .add(new TestComponent("b", new Size(8, 3)), Curses.Location.Bottom) + .add(new TestComponent("l", new Size(2, 4)), Curses.Location.Left) + .add(new TestComponent("r", new Size(3, 6)), Curses.Location.Right) + .build(); + + panel.setSize(new Size(32, 32)); + assertComponentSize(panel, "t", 0, 0, 32, 2); + assertComponentSize(panel, "c", 2, 2, 27, 27); + assertComponentSize(panel, "b", 0, 29, 32, 3); + assertComponentSize(panel, "l", 0, 2, 2, 27); + assertComponentSize(panel, "r", 29, 2, 3, 27); + + panel.setSize(new Size(16, 16)); + assertComponentSize(panel, "t", 0, 0, 16, 2); + assertComponentSize(panel, "c", 2, 2, 11, 11); + assertComponentSize(panel, "b", 0, 13, 16, 3); + assertComponentSize(panel, "l", 0, 2, 2, 11); + assertComponentSize(panel, "r", 13, 2, 3, 11); + + panel.setSize(new Size(10, 10)); + assertComponentSize(panel, "t", 0, 0, 10, 2); + assertComponentSize(panel, "c", 2, 2, 6, 6); + assertComponentSize(panel, "b", 0, 8, 10, 2); + assertComponentSize(panel, "l", 0, 2, 2, 6); + assertComponentSize(panel, "r", 8, 2, 2, 6); + + panel.setSize(new Size(8, 8)); + assertComponentSize(panel, "t", 0, 0, 8, 2); + assertComponentSize(panel, "c", 2, 2, 6, 6); + assertComponentSize(panel, "b", 0, 8, 8, 0); + assertComponentSize(panel, "l", 0, 2, 2, 6); + assertComponentSize(panel, "r", 8, 2, 0, 6); + + panel.setSize(new Size(7, 7)); + assertComponentSize(panel, "t", 0, 0, 7, 1); + assertComponentSize(panel, "c", 1, 1, 6, 6); + assertComponentSize(panel, "b", 0, 7, 7, 0); + assertComponentSize(panel, "l", 0, 1, 1, 6); + assertComponentSize(panel, "r", 7, 1, 0, 6); + } + + private void assertComponentSize(Container panel, String name, int x, int y, int w, int h) { + + Component component = panel.getComponents().stream() + .filter(c -> ((TestComponent) c).name.equals(name)) + .findFirst() + .orElseThrow(IllegalStateException::new); + assertEquals(x, component.getPosition().x(), "bad position: x"); + assertEquals(y, component.getPosition().y(), "bad position: y"); + assertEquals(w, component.getSize().w(), "bad size: w"); + assertEquals(h, component.getSize().h(), "bad size: h"); + } + + private static class TestComponent extends AbstractComponent { + private final String name; + private final Size preferred; + + public TestComponent(String name, Size preferred) { + this.name = name; + this.preferred = preferred; + } + + @Override + protected void doDraw(Screen screen) {} + + @Override + protected Size doGetPreferredSize() { + return preferred; + } + } +} diff --git a/pom.xml b/pom.xml index 966986e25..6599bc408 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,7 @@ remote-ssh remote-telnet style + curses demo graal jansi-core @@ -160,6 +161,12 @@ ${project.version} + + org.jline + jline-curses + ${project.version} + + org.jline jline-builtins