Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
AlanFoster opened this issue Mar 14, 2020 · 33 comments
Open

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

AlanFoster opened this issue Mar 14, 2020 · 33 comments
Labels

Comments

@AlanFoster
Copy link

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 👍

@taras
Copy link
Contributor

taras commented Mar 18, 2020

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

@AlanFoster
Copy link
Author

AlanFoster commented Mar 19, 2020

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

@taras
Copy link
Contributor

taras commented Mar 19, 2020

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

@vadimdemedes
Copy link
Owner

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

@tknickman
Copy link

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.

@amfarrell
Copy link

@tknickman I'd be interested in reading that

@AlanFoster
Copy link
Author

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

@rolfb
Copy link

rolfb commented Apr 27, 2020

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.

@tknickman
Copy link

tknickman commented May 14, 2020

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;

@schemar
Copy link

schemar commented May 23, 2020

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

@tknickman
Copy link

ah for sure great catch!

@schemar
Copy link

schemar commented May 24, 2020

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

@prozacgod
Copy link

prozacgod commented Jan 22, 2021

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

@cahnory
Copy link

cahnory commented Feb 4, 2022

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
Copy link

cahnory commented Feb 6, 2022

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

@vadimdemedes
Copy link
Owner

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

@cedsana
Copy link

cedsana commented Apr 12, 2022

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)

@flash548
Copy link

@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 />);

@DaniGuardiola
Copy link

DaniGuardiola commented Nov 6, 2023

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 #263 (comment) 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.

@vadimdemedes
Copy link
Owner

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?

@DaniGuardiola
Copy link

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

@samifouad
Copy link

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

@prozacgod
Copy link

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

@samifouad
Copy link

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

@DaniGuardiola
Copy link

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

@lgersman
Copy link

lgersman commented Feb 3, 2024

Do it 🙏

@warrenfalk
Copy link

warrenfalk commented Feb 5, 2024

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

@DaniGuardiola
Copy link

DaniGuardiola commented Feb 5, 2024

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

@warrenfalk
Copy link

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

@DaniGuardiola
Copy link

DaniGuardiola commented Feb 5, 2024

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
Copy link

DaniGuardiola commented Feb 6, 2024

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.

@alexgorbatchev
Copy link

alexgorbatchev commented Jun 27, 2024

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?

@alexgorbatchev
Copy link

Going to answer my own question, looks like it's a problem with iTerm2. MacOS default terminal is nearly flicker free.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests