Added custom terminal cursor + fixed style issues
This commit is contained in:
parent
922d01e583
commit
584bf734ac
6 changed files with 143 additions and 35 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import styles from "./Terminal.module.css";
|
import styles from "./Terminal.module.css";
|
||||||
import { useVirtualRoot } from "../../../hooks/virtual-drive/VirtualRootContext.js";
|
import { useVirtualRoot } from "../../../hooks/virtual-drive/VirtualRootContext.js";
|
||||||
import { Command } from "../../../features/applications/terminal/commands.js";
|
import { Command } from "../../../features/applications/terminal/commands.js";
|
||||||
|
import { clamp } from "../../../features/math/clamp.js";
|
||||||
|
|
||||||
const USERNAME = "user";
|
const USERNAME = "user";
|
||||||
const HOSTNAME = "prozilla-os";
|
const HOSTNAME = "prozilla-os";
|
||||||
|
|
@ -27,22 +28,45 @@ function OutputLine({ text }) {
|
||||||
* @param {Function} props.onChange
|
* @param {Function} props.onChange
|
||||||
* @param {Function} props.onKeyUp
|
* @param {Function} props.onKeyUp
|
||||||
* @param {Function} props.onKeyDown
|
* @param {Function} props.onKeyDown
|
||||||
|
* @param {import("react").MutableRefObject} props.inputRef
|
||||||
*/
|
*/
|
||||||
function InputLine({ value, prefix, onChange, onKeyUp, onKeyDown }) {
|
function InputLine({ value, prefix, onChange, onKeyUp, onKeyDown, inputRef }) {
|
||||||
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
|
||||||
|
const checkCursorPosition = () => {
|
||||||
|
setCursorPosition(inputRef.current?.selectionStart);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.Input}>
|
<span className={styles.Input}>
|
||||||
{prefix && <p className={[styles.Prefix]}>{prefix}</p>}
|
{prefix && <p className={[styles.Prefix]}>{prefix}</p>}
|
||||||
<label htmlFor="input"/>
|
<span className={styles["Input-container"]} style={{ "--cursor-offset": cursorPosition }}>
|
||||||
<input
|
<span aria-hidden="true">{value}</span>
|
||||||
id="input"
|
<input
|
||||||
value={value}
|
id="input"
|
||||||
onChange={onChange}
|
value={value}
|
||||||
onKeyUp={onKeyUp}
|
onChange={(event) => {
|
||||||
onKeyDown={onKeyDown}
|
onChange(event);
|
||||||
spellCheck={false}
|
checkCursorPosition();
|
||||||
autoComplete="off"
|
}}
|
||||||
autoFocus
|
ref={inputRef}
|
||||||
/>
|
onKeyUp={onKeyUp}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
onKeyDown(event);
|
||||||
|
checkCursorPosition();
|
||||||
|
}}
|
||||||
|
onClick={checkCursorPosition}
|
||||||
|
onTouchEnd={checkCursorPosition}
|
||||||
|
onSelect={checkCursorPosition}
|
||||||
|
onCut={checkCursorPosition}
|
||||||
|
onCopy={checkCursorPosition}
|
||||||
|
onPaste={checkCursorPosition}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
size=""
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +77,8 @@ export function Terminal({ setTitle }) {
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const virtualRoot = useVirtualRoot();
|
const virtualRoot = useVirtualRoot();
|
||||||
const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate("~"));
|
const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate("~"));
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(`${USERNAME}@${HOSTNAME}: ${currentDirectory.root ? "/" : currentDirectory.path}`);
|
setTitle(`${USERNAME}@${HOSTNAME}: ${currentDirectory.root ? "/" : currentDirectory.path}`);
|
||||||
|
|
@ -76,7 +102,8 @@ export function Terminal({ setTitle }) {
|
||||||
const submitInput = (value) => {
|
const submitInput = (value) => {
|
||||||
pushHistory({
|
pushHistory({
|
||||||
text: prefix + value,
|
text: prefix + value,
|
||||||
isInput: true
|
isInput: true,
|
||||||
|
value
|
||||||
});
|
});
|
||||||
|
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
|
@ -119,13 +146,38 @@ export function Terminal({ setTitle }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateHistoryIndex = (delta) => {
|
||||||
|
const inputHistory = history.filter(({ isInput }) => isInput);
|
||||||
|
const index = clamp(historyIndex + delta, 0, inputHistory.length);
|
||||||
|
|
||||||
|
if (index === historyIndex) {
|
||||||
|
if (delta < 0) {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
setInputValue("");
|
||||||
|
} else {
|
||||||
|
setInputValue(inputHistory[inputHistory.length - index].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistoryIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
// console.log(event);
|
if (key === "Enter") {
|
||||||
if (event.key === "Enter") {
|
|
||||||
submitInput(value);
|
submitInput(value);
|
||||||
setInputKey((previousKey) => previousKey + 1);
|
setInputKey((previousKey) => previousKey + 1);
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
updateHistoryIndex(1);
|
||||||
|
} else if (key === "ArrowDown") {
|
||||||
|
updateHistoryIndex(-1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,6 +221,12 @@ export function Terminal({ setTitle }) {
|
||||||
className={styles.Terminal}
|
className={styles.Terminal}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (window.getSelection().toString() === "") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{displayHistory()}
|
{displayHistory()}
|
||||||
<InputLine
|
<InputLine
|
||||||
|
|
@ -177,6 +235,8 @@ export function Terminal({ setTitle }) {
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
inputRef={inputRef}
|
||||||
|
history={history}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
.Terminal {
|
.Terminal {
|
||||||
|
--char-width: 0.626rem;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Terminal * {
|
.Terminal * {
|
||||||
font-family: var(--mono-font-family);
|
font-family: var(--mono-font-family);
|
||||||
|
letter-spacing: 0.01rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Terminal p {
|
.Terminal p {
|
||||||
|
|
@ -27,21 +32,54 @@
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
white-space: break-spaces;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input {
|
.Input {
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input input {
|
.Input-container {
|
||||||
width: 100%;
|
--cursor-width: var(--char-width);
|
||||||
|
--cursor-offset: 0;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: var(--char-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input-container::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(var(--cursor-offset) * var(--char-width) * 0.9746124950079872);
|
||||||
|
width: var(--cursor-width);
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--foreground-color-a);
|
||||||
|
animation: blink 1000ms step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input-container input {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
margin-left: 0.45rem;
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
from, to {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: var(--foreground-color-a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input label {
|
.Input label {
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ import { useContextMenu } from "../../hooks/modals/ContextMenu.js";
|
||||||
* @param {boolean} props.focused
|
* @param {boolean} props.focused
|
||||||
* @param {Function} props.onInteract
|
* @param {Function} props.onInteract
|
||||||
* @param {object} props.options
|
* @param {object} props.options
|
||||||
|
* @param {boolean} props.active
|
||||||
*/
|
*/
|
||||||
export const WindowView = memo(function Window({ id, app, size, position, focused = false, onInteract, options }) {
|
export const WindowView = memo(function Window({ id, app, size, position, onInteract, options, active }) {
|
||||||
const windowsManager = useWindowsManager();
|
const windowsManager = useWindowsManager();
|
||||||
const nodeRef = useRef(null);
|
const nodeRef = useRef(null);
|
||||||
const [modalsManager, modals] = useModals();
|
const [modalsManager, modals] = useModals();
|
||||||
|
|
@ -161,7 +162,7 @@ export const WindowView = memo(function Window({ id, app, size, position, focuse
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["Window-content"]}>
|
<div className={styles["Window-content"]}>
|
||||||
<app.WindowContent {...options} app={app} setTitle={setTitle} close={close} focus={focus}/>
|
<app.WindowContent {...options} app={app} setTitle={setTitle} close={close} focus={focus} active={active}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@ export function WindowsView() {
|
||||||
// TO DO: prevent windows from being rerendered when order is changed
|
// TO DO: prevent windows from being rerendered when order is changed
|
||||||
|
|
||||||
return (<div>
|
return (<div>
|
||||||
{sortedWindows.map(({ id, app, size, position, options }) =>
|
{sortedWindows.map(({ id, app, size, position, options }, index) =>
|
||||||
<WindowView
|
<WindowView
|
||||||
onInteract={() => { windowsManager.focus(id); }}
|
onInteract={() => { windowsManager.focus(id); }}
|
||||||
|
active={index === 0}
|
||||||
id={id}
|
id={id}
|
||||||
key={id}
|
key={id}
|
||||||
app={app}
|
app={app}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,15 @@ export const ASCII_LOGO = `
|
||||||
---::..:=======-.
|
---::..:=======-.
|
||||||
:===+=----------::..
|
:===+=----------::..
|
||||||
=+=---------------:..
|
=+=---------------:..
|
||||||
--------------=-----:.
|
--------------------:.
|
||||||
.:-+=---=*#*#*==*#*##=---.
|
.:-+=----*###*--*####=---.
|
||||||
:==+---=#%+=+%#+##**+=---:.
|
:==+----#%+-+%#-##%*+----:.
|
||||||
.=---=#%+=+%*+*++*%+---:.
|
.=----#%+-+%#-*+-%#+---:.
|
||||||
==---=*###*==*###*=---.
|
==----*###*--*###*----.
|
||||||
==+-------------------:.
|
==+-------------------:.
|
||||||
...::---------------:.
|
...::---------------:.
|
||||||
.::---------::..
|
.::---------::..
|
||||||
....::...
|
....::... `;
|
||||||
`;
|
|
||||||
|
|
||||||
export class Command {
|
export class Command {
|
||||||
/**
|
/**
|
||||||
|
|
@ -149,9 +148,10 @@ export class Command {
|
||||||
}),
|
}),
|
||||||
new Command("neofetch", (args, { username, hostname }) => {
|
new Command("neofetch", (args, { username, hostname }) => {
|
||||||
const leftColumn = ASCII_LOGO.split("\n");
|
const leftColumn = ASCII_LOGO.split("\n");
|
||||||
|
const rightColumnWidth = username.length + hostname.length + 1;
|
||||||
const rightColumn = [
|
const rightColumn = [
|
||||||
`${username}@${hostname}`,
|
`${username}@${hostname}`,
|
||||||
"-".repeat(username.length + hostname.length + 1),
|
"-".repeat(rightColumnWidth),
|
||||||
"OS: ProzillaOS",
|
"OS: ProzillaOS",
|
||||||
`UPTIME: ${formatRelativeTime(START_DATE, 2, false)}`,
|
`UPTIME: ${formatRelativeTime(START_DATE, 2, false)}`,
|
||||||
`RESOLUTION: ${window.innerWidth}x${window.innerHeight}`,
|
`RESOLUTION: ${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
|
@ -161,11 +161,14 @@ export class Command {
|
||||||
];
|
];
|
||||||
|
|
||||||
const combined = [];
|
const combined = [];
|
||||||
for (let i = 1; i < leftColumn.length - 1; i++) {
|
for (let i = 1; i < leftColumn.length; i++) {
|
||||||
let line = `${leftColumn[i]} `;
|
let line = `${leftColumn[i]} `;
|
||||||
|
|
||||||
if (i <= rightColumn.length) {
|
if (i <= rightColumn.length) {
|
||||||
line += rightColumn[i - 1];
|
line += rightColumn[i - 1];
|
||||||
|
} else {
|
||||||
|
// This fixes a weird display bug on Safari mobile
|
||||||
|
line += " ".repeat(rightColumnWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
combined.push(line);
|
combined.push(line);
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,8 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 20px;
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
|
|
@ -121,3 +122,7 @@ code {
|
||||||
transition: 200ms ease-in-out;
|
transition: 200ms ease-in-out;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue