Component text can be truncated

- BaseComponentContext has new field terminalWidth.
- StringToStyleExpressionRenderer contains new format
  for "truncate-" prefixes and this is something
  what template can use to instruct max length based
  on terminal width.
- Change single/multi selectors to use this feature.
- Fixes #543
This commit is contained in:
Janne Valkealahti
2022-10-14 17:13:07 +01:00
parent 4c48017a97
commit feba345f00
11 changed files with 198 additions and 15 deletions

View File

@@ -59,6 +59,7 @@ public class MultiItemSelector<T, I extends Nameable & Matchable & Enableable &
}
currentContext = MultiItemSelectorContext.empty(getItemMapper());
currentContext.setName(name);
currentContext.setTerminalWidth(getTerminal().getWidth());
if (currentContext.getItems() == null) {
currentContext.setItems(getItems());
}

View File

@@ -59,6 +59,7 @@ public class SingleItemSelector<T, I extends Nameable & Matchable & Enableable &
}
currentContext = SingleItemSelectorContext.empty(getItemMapper());
currentContext.setName(name);
currentContext.setTerminalWidth(getTerminal().getWidth());
if (currentContext.getItems() == null) {
currentContext.setItems(getItems());
}

View File

@@ -30,6 +30,8 @@ import java.util.stream.Stream;
public class BaseComponentContext<C extends ComponentContext<C>> extends LinkedHashMap<Object, Object>
implements ComponentContext<C> {
private Integer terminalWidth;
@Override
public Object get(Object key) {
Object o = super.get(key);
@@ -60,12 +62,28 @@ public class BaseComponentContext<C extends ComponentContext<C>> extends LinkedH
return entrySet().stream();
}
@Override
public Integer getTerminalWidth() {
return terminalWidth;
}
@Override
public void setTerminalWidth(Integer terminalWidth) {
this.terminalWidth = terminalWidth;
}
@Override
public Map<String, Object> toTemplateModel() {
Map<String, Object> attributes = new HashMap<>();
// hardcoding enclosed map values into 'rawValues'
// as it may contain anything
attributes.put("rawValues", this);
attributes.put("terminalWidth", terminalWidth);
return attributes;
}
@Override
public String toString() {
return "BaseComponentContext [terminalWidth=" + terminalWidth + "]";
}
}

View File

@@ -80,6 +80,20 @@ public interface ComponentContext<C extends ComponentContext<C>> {
*/
Stream<Map.Entry<Object, Object>> stream();
/**
* Get terminal width.
*
* @return a terminal width
*/
Integer getTerminalWidth();
/**
* Set terminal width.
*
* @param terminalWidth the width
*/
void setTerminalWidth(Integer terminalWidth);
/**
* Gets context values as a map. Every context implementation can
* do their own model as essentially what matter is a one coming

View File

@@ -16,6 +16,7 @@
package org.springframework.shell.style;
import java.util.Locale;
import java.util.stream.Stream;
import org.jline.style.StyleExpression;
import org.stringtemplate.v4.AttributeRenderer;
@@ -33,6 +34,7 @@ import org.springframework.util.StringUtils;
public class StringToStyleExpressionRenderer implements AttributeRenderer<String> {
private final ThemeResolver themeResolver;
private final static String TRUNCATE = "truncate-";
public StringToStyleExpressionRenderer(ThemeResolver themeResolver) {
Assert.notNull(themeResolver, "themeResolver must be set");
@@ -44,8 +46,51 @@ public class StringToStyleExpressionRenderer implements AttributeRenderer<String
if (!StringUtils.hasText(formatString)) {
return value;
}
else {
else if (formatString.startsWith("style-")) {
return String.format("@{%s %s}", themeResolver.resolveStyleTag(formatString), value);
}
else if (formatString.startsWith(TRUNCATE)) {
String f = formatString.substring(TRUNCATE.length());
TruncateValues config = mapValues(f);
if (value.length() + config.prefix > config.width) {
return String.format(locale, "%1." + (config.width - config.prefix - 2) + "s.." , value);
}
else {
return value;
}
}
else {
return String.format(locale, formatString, value);
}
}
private static class TruncateValues {
Integer width;
Integer prefix;
public void setWidth(Integer width) {
this.width = width;
}
public void setPrefix(Integer prefix) {
this.prefix = prefix;
}
}
private static TruncateValues mapValues(String expression) {
TruncateValues values = new TruncateValues();
Stream.of(expression.split("-"))
.map(String::trim)
.forEach(v -> {
String[] split = v.split(":", 2);
if (split.length == 2) {
if ("width".equals(split[0])) {
values.setWidth(Integer.parseInt(split[1]));
}
else if ("prefix".equals(split[0])) {
values.setPrefix(Integer.parseInt(split[1]));
}
}
});
return values;
}
}

View File

@@ -1,10 +1,15 @@
// selector rows
truncate(name,model) ::= <%
<name; format={truncate-width:<model.terminalWidth>-prefix:5}>
%>
// used to select style if item is selected/unselected
selected_style(flag) ::= <%
<if(flag)>style-item-selected<else>style-item-unselected<endif>
%>
// selector rows
select_item(item) ::= <%
select_item(item,model) ::= <%
<if(item.onrow)>
<({<figures.rightPointingQuotation> }); format="style-item-selector">
<else>
@@ -13,16 +18,16 @@ select_item(item) ::= <%
<if(item.enabled)>
<if(item.selected)>
<({<figures.checkboxOn> }); format=selected_style(item.selected)> <item.name>
<({<figures.checkboxOn> }); format=selected_style(item.selected)> <truncate(item.name,model)>
<else>
<({<figures.checkboxOff> }); format=selected_style(item.selected)> <item.name>
<({<figures.checkboxOff> }); format=selected_style(item.selected)> <truncate(item.name,model)>
<endif>
<else>
<if(item.selected)>
<({<figures.checkboxOn> }); format="style-item-disabled"> <item.name; format="style-item-disabled">
<({<figures.checkboxOn> }); format="style-item-disabled"> <({<truncate(item.name,model)>}); format="style-item-disabled">
<else>
<({<figures.checkboxOff> }); format="style-item-disabled"> <item.name; format="style-item-disabled">
<endif>
<({<figures.checkboxOff> }); format="style-item-disabled"> <({<truncate(item.name,model)>}); format="style-item-disabled">
<endif>
<endif>
%>
@@ -58,7 +63,7 @@ result(model) ::= <<
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<model.rows:{x|<select_item(x)>}; separator="\n">
<model.rows:{x|<select_item(x,model)>}; separator="\n">
>>
// main - hardcoded name

View File

@@ -1,9 +1,13 @@
// selector rows
select_item(item) ::= <%
truncate(name,model) ::= <%
<name; format={truncate-width:<model.terminalWidth>-prefix:2}>
%>
select_item(item,model) ::= <%
<if(item.selected)>
<({<figures.rightPointingQuotation> }); format="style-item-selector"><item.name; format="style-item-selector">
<({<figures.rightPointingQuotation> }); format="style-item-selector"><({<truncate(item.name,model)>}); format="style-item-selector">
<else>
<(" ")><item.name>
<(" ")><truncate(item.name,model)>
<endif>
%>
@@ -34,7 +38,7 @@ result(model) ::= <<
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<model.rows:{x|<select_item(x)>}; separator="\n">
<model.rows:{x|<select_item(x,model)>}; separator="\n">
>>
// main - hardcoded name

View File

@@ -80,7 +80,7 @@ public abstract class AbstractShellTests {
pipedInputStream.connect(pipedOutputStream);
terminal = new DumbTerminal("terminal", "ansi", pipedInputStream, consoleOut, StandardCharsets.UTF_8);
terminal.setSize(new Size(1, 1));
terminal.setSize(new Size(80, 24));
executorService.execute(() -> {
try {

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2022 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.style;
import java.util.Locale;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.assertj.core.api.Assertions.assertThat;
class StringToStyleExpressionRendererTests {
private static Locale LOCALE = Locale.getDefault();
private static StringToStyleExpressionRenderer renderer;
@BeforeAll
static void setup() {
ThemeRegistry themeRegistry = new ThemeRegistry();
themeRegistry.register(new Theme() {
@Override
public String getName() {
return "default";
}
@Override
public ThemeSettings getSettings() {
return ThemeSettings.defaults();
}
});
ThemeResolver themeResolver = new ThemeResolver(themeRegistry, "default");
renderer = new StringToStyleExpressionRenderer(themeResolver);
}
@Test
void emptyFormatReturnsValue() {
String value = renderer.toString("fake", null, LOCALE);
assertThat(value).isEqualTo("fake");
}
static Stream<Arguments> truncate() {
return Stream.of(
Arguments.of("0123456789", "truncate-width:6-prefix:2", "01.."),
Arguments.of("0123456789", "truncate-width:6-prefix:0", "0123.."),
Arguments.of("0123456789", "truncate-width:11-prefix:0", "0123456789")
);
}
@ParameterizedTest
@MethodSource
void truncate(String value, String expression, String expected) {
assertThat(renderer.toString(value, expression, LOCALE)).isEqualTo(expected);
}
}

View File

@@ -89,3 +89,14 @@ from a parent component types. The following tables show those context variables
|The current cursor row in a selector.
|===
[[componentcontext-template-variables]]
.ComponentContext Template Variables
|===
|Key |Description
|`terminalWidth`
|The width of terminal, type is _Integer_ and defaults to _NULL_ if not set.
|===

View File

@@ -39,6 +39,7 @@ import org.springframework.shell.component.support.SelectorItem;
import org.springframework.shell.standard.AbstractShellComponent;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.util.StringUtils;
@ShellComponent
@@ -75,10 +76,16 @@ public class ComponentCommands extends AbstractShellComponent {
}
@ShellMethod(key = "component single", value = "Single selector", group = "Components")
public String singleSelector() {
public String singleSelector(
@ShellOption(defaultValue = ShellOption.NULL) Boolean longKeys
) {
List<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2"));
if (longKeys != null && longKeys == true) {
items.add(SelectorItem.of("key3 long long long long long", "value3"));
items.add(SelectorItem.of("key4 long long long long long long long long long long", "value4"));
}
SingleItemSelector<String, SelectorItem<String>> component = new SingleItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());
@@ -90,11 +97,17 @@ public class ComponentCommands extends AbstractShellComponent {
}
@ShellMethod(key = "component multi", value = "Multi selector", group = "Components")
public String multiSelector() {
public String multiSelector(
@ShellOption(defaultValue = ShellOption.NULL) Boolean longKeys
) {
List<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2", false, true));
items.add(SelectorItem.of("key3", "value3"));
if (longKeys != null && longKeys == true) {
items.add(SelectorItem.of("key4 long long long long long", "value4", false, true));
items.add(SelectorItem.of("key5 long long long long long long long long long long", "value5"));
}
MultiItemSelector<String, SelectorItem<String>> component = new MultiItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());