ink icon indicating copy to clipboard operation
ink copied to clipboard

Can ink be used as a "full" screen application?

Open AlanFoster opened this issue 4 years ago • 33 comments

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:

var blessed = require('blessed');

// Create a screen object.
var screen = blessed.screen({
  smartCSR: true
});

var box = blessed.box({
  top: 'center',
  left: 'center',
  width: '50%',
  height: '50%',
  content: 'Hello {bold}world{/bold}!',
  tags: true,
  border: {
    type: 'line'
  },
  style: {
    fg: 'white',
    bg: 'magenta',
    border: {
      fg: '#f0f0f0'
    },
    hover: {
      bg: 'green'
    }
  }
});

screen.append(box);

screen.key(['escape', 'q', 'C-c'], function(ch, key) {
  return process.exit(0);
});

screen.render()

Which opens the "full" screen application, and leaves the original terminal intact after quitting it. Any pointers/direction would be appreciated :+1:

AlanFoster avatar Mar 14 '20 16:03 AlanFoster

@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 avatar Mar 18 '20 22:03 taras

@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:

alt-screen-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.

AlanFoster avatar Mar 19 '20 00:03 AlanFoster

Nice one. It would be nice if it was. Maybe a component that you could wrap around the main container?

taras avatar Mar 19 '20 00:03 taras

@AlanFoster Interesting! Was always wondering how it's being done. @taras Indeed, perhaps there could be a component that would take care of this.

vadimdemedes avatar Mar 28 '20 15:03 vadimdemedes

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 avatar Apr 03 '20 14:04 tknickman

@tknickman I'd be interested in reading that

amfarrell avatar Apr 03 '20 20:04 amfarrell

@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.

AlanFoster avatar Apr 03 '20 20:04 AlanFoster

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.

rolfb avatar Apr 27 '20 07:04 rolfb

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 avatar May 14 '20 14:05 tknickman

@tknickman you want an empty array as second argument to useEffect in FullScreen in order to only execute it once.

schemar avatar May 23 '20 19:05 schemar

ah for sure great catch!

tknickman avatar May 23 '20 19:05 tknickman

@tknickman turns out you also want process.stdout.write(enterAltScreenCommand); to be inside the useEffect so that that is only called once. 😉

schemar avatar May 24 '20 18:05 schemar

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>
	);
};

prozacgod avatar Jan 22 '21 04:01 prozacgod

I also propose my own solution, which is largely inspired by yours and adds some tips.

To summarize my changes:

  • make a reusable hook to get screen size (useScreenSize)
  • enter alt screen in useMemo hook
  • wrap Screen component children with a Box fitting the screen
  • catch input with useInput, preventing a line to be added after the Screen's Box (not too much hindsight on this point but so far so good)
  • get stdout using useStdout hook

Screen.js

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;

useScreenSize.js

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;

cahnory avatar Feb 04 '22 21:02 cahnory

  • 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;

cahnory avatar Feb 06 '22 09:02 cahnory

Tip: you can use ink-use-stdout-dimensions hook to get current number of columns and rows of the terminal.

vadimdemedes avatar Feb 07 '22 14:02 vadimdemedes

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)

cedsana avatar Apr 12 '22 14:04 cedsana

@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:

alt-screen-buffer alt-screen-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 />);

flash548 avatar Jul 13 '23 14:07 flash548

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:

  • Proper responsive fullscreen.
  • Exiting manually (e.g. pressing q) makes the app disappear.
  • Exiting automatically (e.g. ctrl + c) makes the app disappear too.
  • In both cases, the history is preserved.
  • No glitches, which wasn't the case for other set-ups I've tried. For example, after exiting the app in some cases, scrolling with the mouse over the terminal would bring up past commands instead of actually scrolling, which is unusual. I haven't run into anything like that with this solution.

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:

  1. Sending the alternate screen buffer codes exactly like I'm doing here.
  2. Automatically wrapping the component passed to 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.

DaniGuardiola avatar Nov 06 '23 20:11 DaniGuardiola

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 avatar Nov 11 '23 17:11 vadimdemedes

@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:

  • I publish it as a package that wraps ink's render method
  • It is built into ink's render method

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.

DaniGuardiola avatar Nov 12 '23 06:11 DaniGuardiola

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.

demo repo: https://github.com/samifouad/ink-responsive-demo

samifouad avatar Feb 02 '24 07:02 samifouad

@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?

prozacgod avatar Feb 02 '24 07:02 prozacgod

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

samifouad avatar Feb 02 '24 07:02 samifouad

I really need to publish my solution. And if @vadimdemedes agrees I'm still happy to send a PR too.

DaniGuardiola avatar Feb 03 '24 04:02 DaniGuardiola

Do it 🙏

lgersman avatar Feb 03 '24 09:02 lgersman

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 avatar Feb 05 '24 00:02 warrenfalk

@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 avatar Feb 05 '24 14:02 DaniGuardiola

@DaniGuardiola, ah, I see. That makes sense.

In that case, consider:

render() has two purposes here:

  1. do the render
  2. return the instance

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.

warrenfalk avatar Feb 05 '24 18:02 warrenfalk

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.

DaniGuardiola avatar Feb 05 '24 21:02 DaniGuardiola