vadimdemedes / ink

🌈 React for interactive command-line apps
https://term.ink
MIT License
27.28k stars 613 forks source link

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

Open AlanFoster opened 4 years ago

AlanFoster commented 4 years ago

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:

taras commented 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.

AlanFoster commented 4 years ago

@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 commented 4 years ago

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

vadimdemedes commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

@tknickman I'd be interested in reading that

AlanFoster commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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

tknickman commented 4 years ago

ah for sure great catch!

schemar commented 4 years ago

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

prozacgod commented 3 years ago

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 commented 2 years ago

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

To summarize my changes:

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 commented 2 years ago
  • 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 commented 2 years ago

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

cedsana commented 2 years ago

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 commented 1 year ago

@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 commented 1 year ago

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:

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 commented 1 year ago

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 commented 1 year ago

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

samifouad commented 10 months ago

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 commented 10 months ago

@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 commented 10 months ago

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 commented 10 months ago

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

lgersman commented 10 months ago

Do it πŸ™

warrenfalk commented 10 months ago

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 commented 10 months ago

@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 commented 10 months ago

@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 commented 10 months ago

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 commented 10 months ago

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 commented 5 months ago

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 commented 5 months ago

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