From 7fa953f1311bb7a6117a8eec91628bde4f1956fb Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Sat, 10 Feb 2024 15:08:34 +0000 Subject: [PATCH] ViewComponent async execution - Modifies ViewComponent with changed api's so that it can be executed with a thread allowing caller not to block. - Add ViewComponentBuilder concept and create is as a bean similar to TerminalUIBuilder. - Relates #997 --- .../boot/TerminalUIAutoConfiguration.java | 18 ++- .../shell/component/ViewComponent.java | 60 ++++++++-- .../shell/component/ViewComponentBuilder.java | 51 +++++++++ .../component/ViewComponentExecutor.java | 106 ++++++++++++++++++ .../samples/standard/ComponentUiCommands.java | 66 ++++++++++- .../standard/AbstractShellComponent.java | 10 +- 6 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentBuilder.java create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentExecutor.java diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/TerminalUIAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/TerminalUIAutoConfiguration.java index e0b48070..573c2d01 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/TerminalUIAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/TerminalUIAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-2024 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. @@ -23,6 +23,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Scope; +import org.springframework.shell.component.ViewComponentBuilder; +import org.springframework.shell.component.ViewComponentExecutor; import org.springframework.shell.component.view.TerminalUI; import org.springframework.shell.component.view.TerminalUIBuilder; import org.springframework.shell.component.view.TerminalUICustomizer; @@ -45,4 +47,18 @@ public class TerminalUIAutoConfiguration { return builder; } + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + public ViewComponentBuilder viewComponentBuilder(TerminalUIBuilder terminalUIBuilder, + ViewComponentExecutor viewComponentExecutor, Terminal terminal) { + return new ViewComponentBuilder(terminalUIBuilder, viewComponentExecutor, terminal); + } + + @Bean + @ConditionalOnMissingBean + public ViewComponentExecutor viewComponentExecutor() { + return new ViewComponentExecutor(); + } + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponent.java index e1a43a27..ca3e32e6 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponent.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponent.java @@ -17,6 +17,8 @@ package org.springframework.shell.component; import org.jline.terminal.Size; import org.jline.terminal.Terminal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.shell.component.message.ShellMessageBuilder; import org.springframework.shell.component.view.TerminalUI; @@ -33,11 +35,13 @@ import org.springframework.util.Assert; */ public class ViewComponent { + private final static Logger log = LoggerFactory.getLogger(ViewComponent.class); private final Terminal terminal; private final View view; private EventLoop eventLoop; - private TerminalUI ui; + private TerminalUI terminalUI; private boolean useTerminalWidth = true; + private ViewComponentExecutor viewComponentExecutor; /** * Construct view component with a given {@link Terminal} and {@link View}. @@ -45,19 +49,36 @@ public class ViewComponent { * @param terminal the terminal * @param view the main view */ - public ViewComponent(Terminal terminal, View view) { + public ViewComponent(TerminalUI terminalUI, Terminal terminal, ViewComponentExecutor viewComponentExecutor, + View view) { + Assert.notNull(terminalUI, "terminal ui must be set"); Assert.notNull(terminal, "terminal must be set"); Assert.notNull(view, "view must be set"); + this.terminalUI = terminalUI; this.terminal = terminal; this.view = view; - this.ui = new TerminalUI(terminal); - this.eventLoop = ui.getEventLoop(); + this.viewComponentExecutor = viewComponentExecutor; + this.eventLoop = terminalUI.getEventLoop(); + } + + /** + * Run a component asyncronously. Returned state can be used to wait, cancel or + * see its completion status. + * + * @return run state + */ + public ViewComponentRun runAsync() { + ViewComponentRun run = viewComponentExecutor.start(() -> { + runBlocking(); + }); + return run; } /** * Run a view execution loop. */ - public void run() { + public void runBlocking() { + log.debug("Start run()"); eventLoop.onDestroy(eventLoop.viewEvents(ViewDoneEvent.class, view) .subscribe(event -> { exit(); @@ -69,8 +90,9 @@ public class ViewComponent { if (useTerminalWidth) { view.setRect(rect.x(), rect.y(), terminalSize.getColumns() - rect.x(), rect.height()); } - ui.setRoot(view, false); - ui.run(); + terminalUI.setRoot(view, false); + terminalUI.run(); + log.debug("End run()"); } /** @@ -98,4 +120,28 @@ public class ViewComponent { eventLoop.dispatch(ShellMessageBuilder.ofInterrupt()); } + /** + * Represent run state of an async run of a component. + */ + public interface ViewComponentRun { + + /** + * Await component termination. + */ + void await(); + + /** + * Cancel component run. + */ + void cancel(); + + /** + * Returns {@code true} if component run has completed. + * + * @return {@code true} if component run has completed + */ + boolean isDone(); + + } + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentBuilder.java b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentBuilder.java new file mode 100644 index 00000000..db5d94ff --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentBuilder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 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; + +import org.jline.terminal.Terminal; + +import org.springframework.shell.component.view.TerminalUIBuilder; +import org.springframework.shell.component.view.control.View; + +/** + * Builder that can be used to configure and create a {@link ViewComponent}. + * + * @author Janne Valkealahti + */ +public class ViewComponentBuilder { + + private final TerminalUIBuilder terminalUIBuilder; + private final ViewComponentExecutor viewComponentExecutor; + private final Terminal terminal; + + public ViewComponentBuilder(TerminalUIBuilder terminalUIBuilder, ViewComponentExecutor viewComponentExecutor, + Terminal terminal) { + this.terminalUIBuilder = terminalUIBuilder; + this.viewComponentExecutor = viewComponentExecutor; + this.terminal = terminal; + } + + /** + * Build a new {@link ViewComponent} instance and configure it using this builder. + * + * @param view the view to use with view component + * @return a configured {@link ViewComponent} instance. + */ + public ViewComponent build(View view) { + return new ViewComponent(terminalUIBuilder.build(), terminal, viewComponentExecutor, view); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentExecutor.java b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentExecutor.java new file mode 100644 index 00000000..c42c9860 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/ViewComponentExecutor.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 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; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.shell.component.ViewComponent.ViewComponentRun; + +/** + * Executor for {@code ViewComponent}. Purpose of this executor is to run + * component in a thread so that it doesn't need to block from a command. + * + * @author Janne Valkealahti + */ +public class ViewComponentExecutor implements AutoCloseable { + + private final Logger log = LoggerFactory.getLogger(ViewComponentExecutor.class); + private final SimpleAsyncTaskExecutor executor; + private Future future; + + public ViewComponentExecutor() { + this.executor = new SimpleAsyncTaskExecutor(); + } + + @Override + public void close() throws Exception { + this.executor.close(); + } + + private static class FutureViewComponentRun implements ViewComponentRun { + + private Future future; + + private FutureViewComponentRun(Future future) { + this.future = future; + } + + @Override + public void await() { + try { + this.future.get(); + } catch (InterruptedException e) { + } catch (ExecutionException e) { + } + } + + @Override + public void cancel() { + this.future.cancel(true); + } + + @Override + public boolean isDone() { + return this.future.isDone(); + } + + } + + /** + * Execute runnable and return state which can be used for further operations. + * + * @param runnable the runnable + * @return run state + */ + public ViewComponentRun start(Runnable runnable) { + if (future != null && !future.isDone()) { + throw new IllegalStateException("Can run component as there is existing one in non stopped state"); + } + future = executor.submit(() -> { + log.debug("About to run component"); + runnable.run(); + log.debug("Finished run component"); + }); + return new FutureViewComponentRun(future); + } + + /** + * Stop a {@code ViewComponent} which has been previously started with this + * executor. + */ + public void stop() { + if (future != null) { + future.cancel(true); + } + future = null; + } + +} diff --git a/spring-shell-samples/spring-shell-sample-commands/src/main/java/org/springframework/shell/samples/standard/ComponentUiCommands.java b/spring-shell-samples/spring-shell-sample-commands/src/main/java/org/springframework/shell/samples/standard/ComponentUiCommands.java index 0f12ae4a..eedd25f9 100644 --- a/spring-shell-samples/spring-shell-sample-commands/src/main/java/org/springframework/shell/samples/standard/ComponentUiCommands.java +++ b/spring-shell-samples/spring-shell-sample-commands/src/main/java/org/springframework/shell/samples/standard/ComponentUiCommands.java @@ -23,6 +23,7 @@ import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; import org.springframework.shell.command.annotation.Command; import org.springframework.shell.component.ViewComponent; +import org.springframework.shell.component.ViewComponent.ViewComponentRun; import org.springframework.shell.component.message.ShellMessageBuilder; import org.springframework.shell.component.message.ShellMessageHeaderAccessor; import org.springframework.shell.component.message.StaticShellMessageHeaderAccessor; @@ -90,14 +91,15 @@ public class ComponentUiCommands extends AbstractShellComponent { public String stringInput() { InputView view = new InputView(); view.setRect(0, 0, 10, 1); - ViewComponent component = new ViewComponent(getTerminal(), view); - component.run(); + ViewComponent component = getViewComponentBuilder().build(view); + component.runBlocking(); String input = view.getInputText(); return String.format("Input was '%s'", input); } private void runProgress(ProgressView view) { - ViewComponent component = new ViewComponent(getTerminal(), view); + ViewComponent component = getViewComponentBuilder().build(view); + EventLoop eventLoop = component.getEventLoop(); Flux> ticks = Flux.interval(Duration.ofMillis(100)).map(l -> { @@ -118,7 +120,7 @@ public class ComponentUiCommands extends AbstractShellComponent { } })); - component.run(); + component.runAsync().await(); } @Command(command = "componentui progress1") @@ -166,4 +168,60 @@ public class ComponentUiCommands extends AbstractShellComponent { runProgress(view); } + @Command(command = "componentui progress5") + public void progress5() { + ProgressView view = new ProgressView(0, 100, + ProgressViewItem.ofText(10, HorizontalAlign.LEFT), + ProgressViewItem.ofSpinner(3, HorizontalAlign.LEFT), + ProgressViewItem.ofPercent(0, HorizontalAlign.RIGHT)); + + view.setDescription("name"); + view.setRect(0, 0, 20, 1); + view.start(); + + ViewComponent component = getViewComponentBuilder().build(view); + component.setUseTerminalWidth(false); + + Flux> ticks = Flux.interval(Duration.ofMillis(100)).map(l -> { + Message message = MessageBuilder + .withPayload(l) + .setHeader(ShellMessageHeaderAccessor.EVENT_TYPE, EventLoop.Type.USER) + .build(); + return message; + }); + EventLoop eventLoop = component.getEventLoop(); + eventLoop.dispatch(ticks); + eventLoop.onDestroy(eventLoop.events() + .filter(m -> EventLoop.Type.USER.equals(StaticShellMessageHeaderAccessor.getEventType(m))) + .subscribe(m -> { + if (m.getPayload() instanceof Long) { + view.tickAdvance(5); + eventLoop.dispatch(ShellMessageBuilder.ofRedraw()); + } + })); + + ViewComponentRun run = component.runAsync(); + + for (int i = 0; i < 4; i++) { + + if (run.isDone()) { + break; + } + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + } + if (run.isDone()) { + break; + } + + String msg = String.format("%s ", i); + getTerminal().writer().write(msg + System.lineSeparator()); + getTerminal().writer().flush(); + + } + + run.cancel(); + } + } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java index cd584ae4..f1951328 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2022 the original author or authors. + * Copyright 2021-2024 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. @@ -29,6 +29,7 @@ import org.springframework.core.io.ResourceLoader; import org.springframework.shell.Shell; import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.completion.CompletionResolver; +import org.springframework.shell.component.ViewComponentBuilder; import org.springframework.shell.style.TemplateExecutor; import org.springframework.shell.style.ThemeResolver; @@ -55,6 +56,8 @@ public abstract class AbstractShellComponent implements ApplicationContextAware, private ObjectProvider themeResolverProvider; + private ObjectProvider viewComponentBuilderProvider; + @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; @@ -73,6 +76,7 @@ public abstract class AbstractShellComponent implements ApplicationContextAware, completionResolverProvider = applicationContext.getBeanProvider(CompletionResolver.class); templateExecutorProvider = applicationContext.getBeanProvider(TemplateExecutor.class); themeResolverProvider = applicationContext.getBeanProvider(ThemeResolver.class); + viewComponentBuilderProvider = applicationContext.getBeanProvider(ViewComponentBuilder.class); } protected ApplicationContext getApplicationContext() { @@ -106,4 +110,8 @@ public abstract class AbstractShellComponent implements ApplicationContextAware, protected ThemeResolver getThemeResolver() { return themeResolverProvider.getObject(); } + + protected ViewComponentBuilder getViewComponentBuilder() { + return viewComponentBuilderProvider.getObject(); + } }