rensbreur / SwiftTUI

SwiftUI for terminal applications

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Caller's control over the rendering

pepicrft opened this issue · comments

Hi 👋
First of all, thanks a lot for building this Swift Package. It's such a fundamental piece for building more powerful terminal experiences.
We'd like to start using the SwiftUI-like for some Tuist components, but I noticed the design assumes that they'll be used in an app that takes control of the terminal window. Have you thought about giving control to the caller to render one-off component, for example, a status bar. I'd like to render it, update the state from the outside, and let the render auto-update when the state changes. When the state reaches 100%, I'd like then to continue the execution and have the standard pipelines control back.

Is this something that aligns with the plans for the package? If so, I'd be happy to make contributions with some guidelines.

Thanks once again.

Hi, thanks for opening this issue!

Do you have some more ideas on how this should work?

If I understand it correctly, we'd need these features:

  • Ability to stop the SwiftTUI run loop without exiting the program.
  • Ability to run a SwiftTUI app in only a portion of the terminal window, and exit without clearing or restoring the screen.

Updating the state "from the outside" could then already be achieved by passing an observable view model to the app's root view.

I had the idea before of allowing apps to run "in line", so they have some (maybe fixed) height, and are shown below any commands run before in the terminal. Sounds like that would fit this use case. I don't know how hard this is to implement.

Exactly. There are two scenarios:

Scenario 1

The component represents an asynchronous event, for example, the download of files. In that case, I have a component whose state changes over time until it eventually completes, and then I expect the component to be unmounted.

let download: AsyncThrowingStream<Float> = ...
let renderer = Renderer()
let progress = Progress(name: "Downloading", percentage: 0.0) // UI component
let mountable = renderer.mount(progress)
for try await percentage from stream download {
  progress.percentage = percentage // This one should trigger a re-render
}
mountable.unmount()

Scenario 2

The component doesn't have a state that mutates over time, for example, a box that represents an error. In that case, I want to render the component in the terminal and that's it:

let error: Error = ...
let errorBox = ErrorBox(error: error)
let renderer = Renderer()
renderer.render(errorBox)

Idea

Perpahs, components can fall into two categories, those that hold a state that mutates over time, and those that don't have state, and a renderer can have two versions of the mount function:

func mount<V: MutableStateView>(_ view: View) -> some Mountable
func mount<V: InmutableStateView>(_ view: View)

In case it's useful, I used Ink with NodeJS, which does something along the lines of SwiftTUI but for NodeJS using React as the DSL.

What I'm afraid of is that adding the functionality to use SwiftTUI only to render views like this (in addition to how it currently works which is more like SwiftUI with its own run loop and inversion of control), is that it would add a lot of complexity. That is why I was wondering if would be enough to be able to exit the run loop and control the views through an ObservableObject, instead of directly from the outside.

I do of course see how this functionality would be very useful. I will have a look at Ink and how it works soon.

It might even make sense to derive a project off of SwiftTUI for this type of rendering. It would not need many of the more complicated parts of SwiftTUI, like the State property wrapper or even diffing, as there won't need to be stateful subviews as far as I can see.

What I'm afraid of is that adding the functionality to use SwiftTUI only to render views like this (in addition to how it currently works which is more like SwiftUI with its own run loop and inversion of control), is that it would add a lot of complexity. That is why I was wondering if would be enough to be able to exit the run loop and control the views through an ObservableObject, instead of directly from the outside.

That's very reasonable. You are the one who knows the project the best as to provide the most sensible solution to the problem 😀. What would I need to do to make it work with an OvservableObject? Is that currently supported?

ObservedObject should work on macOS. (But it's not possible yet to stop SwiftTUI without exiting the program, it uses Dispatch and calls dispatchMain and I'm not sure if theres a way to get out of that)

Hi @rensbreur 👋🏼
We want to use SwiftTUI to make some CLI workflows interactive, and I would like your thoughts on building interactive components using SwiftTUI.
For example, if I wanted to implement a Select component that doesn't take the full width/height of the terminal and leaves the control after the selection has happened, how can I model that with SwiftTUI?
I really appreciate any help you can provide.

Hi @pepicrft, that sounds like a great idea!

Components that don't take the full width/height of the terminal is not supported yet but it's on my wish list. Unfortunately I don't have time to implement this in the coming weeks. But if you want to start to look into this already, I would point you to the Renderer and Application classes. Changing the size shouldn't be the hard part, but there will be some offsetting required to make it draw at the bottom of the terminal window. I'd be more than happy to help you later when I have some more time.

Regarding returning control after the selection has happened, I'm not sure how to achieve this yet. SwiftTUI depends on Dispatch to observe keyboard input and schedule events such as redraws and view evaluations to the main queue at the correct points in time (just like SwiftUI or UIKit apps). There's two ways to start dispatch I know of, either by calling dispatchMain(), or (on macOS) by starting an NSRunLoop. When starting a SwiftTUI Application, you can choose if you want to use Dispatch (which calls dispatchMain()) or Cocoa (which starts an NSRunLoop). I'm thinking of adding a third option of doing nothing, meaning that depends on a main Dispatch queue already having been set up.

I'm not aware of any way to "get out" of Dispatch without exiting the program. The alternative would be to keep Dispatch running after stopping the rest of the SwiftTUI app, or to already call dispatchMain() in the beginning of the interactive workflow and pushing the actual logic to the main queue, and exiting the app when that is done. Some research is required here.