Open AlanFoster opened 4 years ago
@AlanFoster you can definitely do this by playing around the components that Ink comes with. You can render a Box component that has height that is the same height as terminal and it'll be full screen. Because Ink uses Yoga, which provides Flexbox APIs, the approach is similar to how you'd create a full screen web app with Box components.
@taras I learnt more about curses/ncurses and terminfo, via man terminfo
.
I found out you can use ansi escape codes to swap to an alternate screen buffer for 'full screen' applications, and once you're done you can toggle back to the main buffer:
const enterAltScreenCommand = '\x1b[?1049h';
const leaveAltScreenCommand = '\x1b[?1049l';
process.stdout.write(enterAltScreenCommand);
process.on('exit', () => {
process.stdout.write(leaveAltScreenCommand);
});
Therefore as a full example:
const React = require("react");
const { render, Color, useApp, useInput } = require("ink");
const Counter = () => {
const [counter, setCounter] = React.useState(0);
const { exit } = useApp();
React.useEffect(() => {
const timer = setInterval(() => {
setCounter(prevCounter => prevCounter + 1);
}, 100);
return () => {
clearInterval(timer);
};
});
useInput((input, key) => {
if (input === "q" || key.escape) {
exit();
}
});
return <Color green>{counter} tests passed</Color>;
};
const enterAltScreenCommand = "\x1b[?1049h";
const leaveAltScreenCommand = "\x1b[?1049l";
process.stdout.write(enterAltScreenCommand);
process.on("exit", () => {
process.stdout.write(leaveAltScreenCommand);
});
render(<Counter />);
Working example that doesn't conflict with the existing buffer:
That seems to be what I was after - I'm not sure if that's something that should be baked into ink
or not.
Nice one. It would be nice if it was. Maybe a component that you could wrap around the main container?
@AlanFoster Interesting! Was always wondering how it's being done. @taras Indeed, perhaps there could be a component that would take care of this.
Nice tips @AlanFoster! I'm using this for a CLI I'm working on now and have wrapped it into a small component locally. Happy to publish that if you would find it useful / weren't planning on pushing one up yourself.
@tknickman I'd be interested in reading that
@tknickman Sure, go ahead πI think doing that in a cross platform way will be an interesting challenge. It would be also be interesting to see what layouts/other components make sense to develop in a world of full screen cli apps.
This is probably a bit on the side of this issue, but I'm just getting started using Ink and want to create a "full screen app". Was wondering what the trick is to keep the "app" alive instead of exiting? Basically don't exit until I hit Q or ^C.
For what it's worth I just ended up using this for Full Screen. Not really enough code to warrant publishing it imo - but works great!
import { useEffect } from 'react';
const enterAltScreenCommand = '\x1b[?1049h';
const leaveAltScreenCommand = '\x1b[?1049l';
const exitFullScreen = () => {
process.stdout.write(leaveAltScreenCommand);
};
const FullScreen = ({ children }) => {
useEffect(() => {
// destroy alternate screen on unmount
return exitFullScreen;
}, []);
// trigger alternate screen
process.stdout.write(enterAltScreenCommand);
return children;
};
export { exitFullScreen };
export default FullScreen;
@tknickman you want an empty array as second argument to useEffect
in FullScreen
in order to only execute it once.
ah for sure great catch!
@tknickman turns out you also want process.stdout.write(enterAltScreenCommand);
to be inside the useEffect
so that that is only called once. π
Just wanted to add a bit in here, kinda an old thread but...
This is my fullscreen (typescript) component, it tracks the process.stdout resize event and updates a box on resize, works nicely
const FullScreen: React.FC = (props) => {
const [size, setSize] = useState({
columns: process.stdout.columns,
rows: process.stdout.rows,
});
useEffect(() => {
function onResize() {
setSize({
columns: process.stdout.columns,
rows: process.stdout.rows,
});
}
process.stdout.on("resize", onResize);
process.stdout.write("\x1b[?1049h");
return () => {
process.stdout.off("resize", onResize);
process.stdout.write("\x1b[?1049l");
};
}, []);
return (
<Box width={size.columns} height={size.rows}>
{props.children}
</Box>
);
};
I also propose my own solution, which is largely inspired by yours and adds some tips.
To summarize my changes:
import React, { useEffect, useMemo } from "react";
import { Box } from "ink";
import useScreenSize from "./useScreenSize.js";
const Screen = ({ children }) => {
const { height, width } = useScreenSize();
const { stdout } = useStdout();
useMemo(() => stdout.write("\x1b[?1049h"), [stdout]);
useEffect(() => () => stdout.write("\x1b[?1049l"), [stdout]);
useInput(() => {});
return <Box height={height} width={width}>{children}</Box>;
};
export default Screen;
import { useCallback, useEffect, useState } from "react";
import { useStdout } from "ink";
const useScreenSize = () => {
const { stdout } = useStdout();
const getSize = useCallback(
() => ({
height: stdout.rows,
width: stdout.columns,
}),
[stdout],
);
const [size, setSize] = useState(getSize);
useEffect(() => {
const onResize = () => setSize(getSize());
stdout.on("resize", onResize);
return () => stdout.off("resize", onResize);
}, [stdout, getSize]);
return size;
};
export default useScreenSize;
- enter alt screen in useMemo hook
I have not tested it with ink but useMemo and useEffect should probably be replaced with useLayoutEffect
- import React, { useEffect, useMemo } from "react";
+ import React, { useLayoutEffect } from "react";
- import { Box } from "ink";
+ import { Box, useInput, useStdout } from "ink";
import useScreenSize from "./useScreenSize.js";
const Screen = ({ children }) => {
const { height, width } = useScreenSize();
const { stdout } = useStdout();
- useMemo(() => stdout.write("\x1b[?1049h"), [stdout]);
- useEffect(() => () => stdout.write("\x1b[?1049l"), [stdout]);
+ useLayoutEffect(() => {
+ stdout.write("\x1b[?1049h");
+ return () => stdout.write("\x1b[?1049l");
+ } , [stdout]);
useInput(() => {});
return <Box height={height} width={width}>{children}</Box>;
};
export default Screen;
Tip: you can use ink-use-stdout-dimensions hook to get current number of columns and rows of the terminal.
I have an issue. Here is the hook I came up with:
import { useEffect, useMemo } from "react";
import { useStdout } from "ink";
/**
* Hook used to take over the entire screen available in the terminal.
* Will restore the previous content when unmounting.
*/
const useWholeSpace = () => {
const { stdout } = useStdout();
// Trick to force execution before painting
useMemo(() => {
stdout?.write("\x1b[?1049h");
}, [stdout]);
useEffect(() => {
if (stdout) {
return () => {
stdout.write("\x1b[?1049l");
};
}
}, [stdout]);
};
export default useWholeSpace;
It does what I want but it makes ink
sometime miss inputs. I have a table in which I select rows by using the UP or DOWN arrow keys but sometimes my keypress is missed and the selection doesn't move.
Here is what I do:
const [selection, setSelection] = React.useState(0);
useInput((input, key) => {
if (input === "q") {
exit();
}
if (key.upArrow) {
setSelection((old) => old - 1);
// setSelection(selection - 1);
}
if (key.downArrow) {
setSelection((old) => old + 1);
// setSelection(selection + 1);
}
});
The commented code is an attempt at debugging but it didn't change anything.
If I comment my useWholeSpace();
line, the inputs are never missed and are a bit more responsive. (Even though my screen blinks which is a bit annoying)
@taras I learnt more about curses/ncurses and terminfo, via
man terminfo
.I found out you can use ansi escape codes to swap to an alternate screen buffer for 'full screen' applications, and once you're done you can toggle back to the main buffer:
const enterAltScreenCommand = '\x1b[?1049h'; const leaveAltScreenCommand = '\x1b[?1049l'; process.stdout.write(enterAltScreenCommand); process.on('exit', () => { process.stdout.write(leaveAltScreenCommand); });
Therefore as a full example:
const React = require("react"); const { render, Color, useApp, useInput } = require("ink"); const Counter = () => { const [counter, setCounter] = React.useState(0); const { exit } = useApp(); React.useEffect(() => { const timer = setInterval(() => { setCounter(prevCounter => prevCounter + 1); }, 100); return () => { clearInterval(timer); }; }); useInput((input, key) => { if (input === "q" || key.escape) { exit(); } }); return <Color green>{counter} tests passed</Color>; }; const enterAltScreenCommand = "\x1b[?1049h"; const leaveAltScreenCommand = "\x1b[?1049l"; process.stdout.write(enterAltScreenCommand); process.on("exit", () => { process.stdout.write(leaveAltScreenCommand); }); render(<Counter />);
Working example that doesn't conflict with the existing buffer:
That seems to be what I was after - I'm not sure if that's something that should be baked into
ink
or not.
or in Deno
import React, { useState, useEffect } from "npm:react";
import { render, Box, Text, useInput } from "npm:ink";
const Test = () => {
useInput((input, key) => {
if (input === "q") {
Deno.exit(0);
}
});
return (
<Box width="50%" height="50%" borderStyle="single">
<Text color="green">Hello</Text>
</Box>
);
};
const enterAltScreenCommand = "\x1b[?1049h";
const leaveAltScreenCommand = "\x1b[?1049l";
await Deno.stdout.write(new TextEncoder().encode(enterAltScreenCommand));
globalThis.addEventListener("unload", async () => {
await Deno.stdout.write(new TextEncoder().encode(leaveAltScreenCommand));
});
render(<Test />);
For those who just want their app to be fullscreen all the time, you can do the following:
import { render } from "ink";
import { App } from "./ui/App.js";
async function write(content: string) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(content, (error) => {
if (error) reject(error);
else resolve();
});
});
}
await write("\x1b[?1049h");
const instance = render(<App />);
await instance.waitUntilExit();
await write("\x1b[?1049l");
And then combine that with the Screen
component from https://github.com/vadimdemedes/ink/issues/263#issuecomment-1030379127 without the stdout logic, which can be entirely removed.
Another important note is that the app needs to be exited through useApp
's exit
method, e.g.
const app = useApp();
useInput((input) => {
if (input === "q") app.exit();
});
By doing it this way, you get:
q
) makes the app disappear.ctrl + c
) makes the app disappear too.I've spent quite a bit of time navigating issues, code, and terminal documentation to be fairly confident that this is the best solution as long as you don't want to toggle fullscreen during the execution of your CLI app.
@vadimdemedes I wonder if there could be a fullscreen
option in render that is implemented by doing something quite simple:
render
with a built-in component like Screen
.Then fullscreen apps would be as easy as render(<App />, { fullscreen: true })
.
I'd be happy to contribute this change (if you agree this is an acceptable solution), as long as you point me to the right places in the codebase.
Alternate screen buffer is a good idea! I think fullscreen
might not be the most fitting name for it though, since technically it doesn't have to do anything with the app being fullscreen, but rather rendering it into a separate screen which disappears once CLI exits.
I'm kind of on the fence about having this built in, because it's a very niche use case and it looks like it can be achieved quite simply outside Ink.
By the way, is converting process.stdout.write
to a promise necessary though? Does it not work without it?
@vadimdemedes the reason I create a promise wrapper over write is that I'm relying on top level await to make sure the writes are done before doing the next thing, in this case I need to write the code to enter alternate buffer before rendering.
I have created my own "renderFullscreen" method that basically does this for me, plus wraps the tree in a FullScreen component automatically.
However there is an important API change, now instead of returning the instance synchronously it returns a promise with it.
I wanna make this available to the community, and I see two options:
The first would mean having to await for the instance, while the second can probably be achieved without changing the API, since I'm sure it's doable with access to the internals.
So, your call! I definitely think there are tradeoffs to both. For example, alternative buffer is definitely not the same as fullscreen, but the combination of the two is a very common pattern in terminal apps (vim, top, less, more, etc).
If integrated into Ink, I guess there should be two options: alternate buffer and fullscreen. Fullscreen would be alternate buffer + fullscreen wrapper component.
to answer the original question: yes
bonus: it can be responsive, too! I made a small demo showing how ink can be used for this purpose. please excuse the jank. this demo is designed as just an exercise in what's possible. please hack away and perhaps use one of the different methods to enter/exit buffer mentioned here in this thread.
@samifouad I can be totally off base for how this all works but I realized you had render in there twice and so it renders once and then you render again if it resizes does that break any state, I would assume it works similar to react and that would just do the right thing. But I guess it was just a lingering question for me. If you create like a button up-down counter and you clicked it a few times and then you resized, does it lose state?
as that demo works now, it will
but there are definitely better ways to structure that entry point to avoid losing state
eg. setting global state, prop drilling
i will likely update demo with a more polished entry in the future, but I just wanted to give people a rough jumping off point to hack something better
I really need to publish my solution. And if @vadimdemedes agrees I'm still happy to send a PR too.
Do it π
I believe stdout.write
is synchronous and so those awaits have no effect, right? So the only await is on waitUntilExit()
and the only purpose of that is to do cleanup. So I think that a fullScreen
option could just be added to render
, which would just return the instance as always and internally get the waitUntilExit()
promise and add a cleanup routine to that via then()
.
I am currently using the following which works perfectly
export const renderFullScreen = (element: React.ReactNode, options?: RenderOptions) => {
process.stdout.write('\x1b[?1049h');
const instance = render(<FullScreen>{element}</FullScreen>);
instance.waitUntilExit()
.then(() => process.stdout.write('\x1b[?1049l'))
return instance;
}
along with <FullScreen>
which for now is as simple as:
function useStdoutDimensions(): [number, number] {
const {columns, rows} = process.stdout;
const [size, setSize] = useState({columns, rows});
useEffect(() => {
function onResize() {
const {columns, rows} = process.stdout;
setSize({columns, rows});
}
process.stdout.on("resize", onResize);
return () => {
process.stdout.off("resize", onResize);
};
}, []);
return [size.columns, size.rows];
}
const FullScreen: React.FC<PropsWithChildren<BoxProps>> = ({children, ...styles}) => {
const [columns, rows] = useStdoutDimensions();
return <Box width={columns} height={rows} {...styles}>{children}</Box>;
}
@warrenfalk it's been a long time and I'm afk at the moment, so I don't remember exactly, but I think it is asynchronous but in callback style and what I did was promisify it so it could be used with await. I also remember it not working consistently without doing this, likely because of some kind of race condition.
@DaniGuardiola, ah, I see. That makes sense.
In that case, consider:
render()
has two purposes here:
The only question is whether it really needs to be in that order. Does the caller need to know that the render has already occurred when the function returns? I don't think so.
So it's possible to do a render(null, options)
to get the instance, then in the background await the write, rerender(element)
, await unmount, await the write.
A PR could do this without a rerender. The internals allow getting the instance independently.
But it is still a question of whether the render needs to be done before the function returns.
I will release a package tomorrow to fix this once and for all. Here's a fragment of the README in case you have any questions/feedback:
Edit: removed it to reduce noise in this issue. The package is published now though, see my next comment.
Here you go: https://github.com/DaniGuardiola/fullscreen-ink
Enjoy! Let me know if you run into any issues.
Shoutout to @warrenfalk for the double render idea, which I've implemented in my package to allow returning the instance synchronously.
Does anyone else get screen flickering very obviously on re-renders? Especially so when there's a <Spinner />
component? Is there a solution to this?
Going to answer my own question, looks like it's a problem with iTerm2. MacOS default terminal is nearly flicker free.
Hey; I love the concept of this library. Just wondering if it's possible to make a "full" screen application using ink? By that I mean, something like
htop
which takes over the full screen, and when you quit - it leaves your original terminal completely in tact.This is something that can be done with python's curses library, or when using blesses:
Which opens the "full" screen application, and leaves the original terminal intact after quitting it. Any pointers/direction would be appreciated :+1: