Custom terminal fonts support (#5414)

Fixes #5132 #5133 
- bundled 7 new monospaced fonts for terminal
- fix: refresh terminal font after changing in the preferences / keep cluster iframe accessible in DOM while not active/focused
- display terminal's custom font preview with font-name in the select-box + live-preload for current `fontSize`, fix lint

* Fixes for <Input/>:
- remove duplicated error messages for sync validators
- don't propagate invalid values on change (uncontrolled components only)
- more informative error message for numeric input with min/max info
This commit is contained in:
Roman authored and GitHub committed 2022-06-06 18:03:12 +03:00
1 parent fae6bff6fb
commit 55a977554d
38 files changed
+325 -146

No files matched your search

@@ -66,7 +66,7 @@
.notes {
white-space: pre-line;
font-family: "RobotoMono", monospace;
font-family: var(--font-monospace);
font-size: small;
}
@@ -4,18 +4,20 @@
*/
import React from "react";
import { action } from "mobx";
import { observer } from "mobx-react";
import type { UserStore } from "../../../common/user-store";
import { SubTitle } from "../layout/sub-title";
import { Input, InputValidators } from "../input";
import { Input } from "../input";
import { Switch } from "../switch";
import { Select } from "../select";
import { Select, type SelectOption } from "../select";
import type { ThemeStore } from "../../themes/store";
import { Preferences } from "./preferences";
import { withInjectables } from "@ogre-tools/injectable-react";
import userStoreInjectable from "../../../common/user-store/user-store.injectable";
import themeStoreInjectable from "../../themes/store.injectable";
import defaultShellInjectable from "./default-shell.injectable";
import logger from "../../../common/logger";
interface Dependencies {
userStore: UserStore;
@@ -23,11 +25,12 @@ interface Dependencies {
defaultShell: string;
}
const NonInjectedTerminal = observer(({
userStore,
themeStore,
defaultShell,
}: Dependencies) => {
const NonInjectedTerminal = observer((
{
userStore,
themeStore,
defaultShell,
}: Dependencies) => {
const themeOptions = [
{
value: "", // TODO: replace with a sentinal value that isn't string (and serialize it differently)
@@ -39,6 +42,26 @@ const NonInjectedTerminal = observer(({
})),
];
// fonts must be declared in `fonts.scss` and at `template.html` (if early-preloading required)
const supportedCustomFonts: SelectOption<string>[] = [
"RobotoMono", "Anonymous Pro", "IBM Plex Mono", "JetBrains Mono", "Red Hat Mono",
"Source Code Pro", "Space Mono", "Ubuntu Mono",
].map(customFont => {
const { fontFamily, fontSize } = userStore.terminalConfig;
return {
label: <span style={{ fontFamily: customFont, fontSize }}>{customFont}</span>,
value: customFont,
isSelected: fontFamily === customFont,
};
});
const onFontFamilyChange = action(({ value: fontFamily }: SelectOption<string>) => {
logger.info(`setting terminal font to ${fontFamily}`);
userStore.terminalConfig.fontFamily = fontFamily; // save to external storage
});
return (
<Preferences data-testid="terminal-preferences-page">
<section>
@@ -49,7 +72,7 @@ const NonInjectedTerminal = observer(({
<Input
theme="round-black"
placeholder={defaultShell}
value={userStore.shell}
value={userStore.shell ?? ""}
onChange={(value) => userStore.shell = value}
/>
</section>
@@ -81,18 +104,19 @@ const NonInjectedTerminal = observer(({
theme="round-black"
type="number"
min={10}
validators={InputValidators.isNumber}
value={userStore.terminalConfig.fontSize.toString()}
max={50}
defaultValue={userStore.terminalConfig.fontSize.toString()}
onChange={(value) => userStore.terminalConfig.fontSize = Number(value)}
/>
</section>
<section>
<SubTitle title="Font family" />
<Input
theme="round-black"
type="text"
<Select
themeName="lens"
controlShouldRenderValue
value={userStore.terminalConfig.fontFamily}
onChange={(value) => userStore.terminalConfig.fontFamily = value}
options={supportedCustomFonts}
onChange={onFontFamilyChange as any}
/>
</section>
</section>
+23 -12
View File
@@ -12,12 +12,14 @@
@import "./fonts";
:root {
--flex-gap: #{$padding};
--unit: 8px;
--padding: var(--unit);
--margin: var(--unit);
--border-radius: 3px;
--font-main: 'Roboto', 'Helvetica', 'Arial', sans-serif;
--font-monospace: Lucida Console, Monaco, Consolas, monospace;
--font-monospace: Lucida Console, Monaco, Consolas, monospace; // some defaults
--font-terminal: var(--font-monospace); // overridden in terminal.ts, managed by common/user-store.ts
--font-size-small: calc(1.5 * var(--unit));
--font-size: calc(1.75 * var(--unit));
--font-size-big: calc(2 * var(--unit));
@@ -63,16 +65,13 @@
color: var(--textColorAccent);
}
html {
font-size: 62.5%; // 1 rem == 10px
color: var(--textColorPrimary);
background-color: var(--mainBackground);
--flex-gap: #{$padding};
}
html, body {
height: 100%;
overflow: hidden;
color: var(--textColorPrimary);
background-color: var(--mainBackground);
font-size: var(--font-size);
font-family: var(--font-main);
}
#terminal-init {
@@ -93,10 +92,6 @@ html, body {
}
}
body {
font: $font-size $font-main;
}
fieldset {
border: 0;
padding: 0;
@@ -227,6 +222,22 @@ a {
}
}
#fonts-preloading {
> span {
position: absolute;
visibility: hidden;
height: 0;
&:before {
width: 0;
display: block;
overflow: hidden;
content: "text-example"; // some text required to start applying/rendering font in document
font-family: inherit; // font-family must be specified via style="" (see: template.html)
}
}
}
// app's common loading indicator, displaying on the route transitions
#loading {
position: absolute;
@@ -45,7 +45,6 @@ export class ClusterFrameHandler {
iframe.id = `cluster-frame-${cluster.id}`;
iframe.name = cluster.contextName;
iframe.style.display = "none";
iframe.setAttribute("src", getClusterFrameUrl(clusterId));
iframe.addEventListener("load", action(() => {
logger.info(`[LENS-VIEW]: frame for clusterId=${clusterId} has loaded`);
@@ -95,7 +94,7 @@ export class ClusterFrameHandler {
ipcRenderer.send(clusterVisibilityHandler);
for (const { frame: view } of this.views.values()) {
view.style.display = "none";
view.classList.add("hidden");
}
const cluster = clusterId
@@ -113,9 +112,9 @@ export class ClusterFrameHandler {
return undefined;
},
(view) => {
(view: LensView) => {
logger.info(`[LENS-VIEW]: cluster id=${clusterId} should now be visible`);
view.frame.style.display = "flex";
view.frame.classList.remove("hidden");
ipcRenderer.send(clusterVisibilityHandler, clusterId);
},
);
@@ -35,7 +35,20 @@
background-color: var(--mainBackground);
iframe {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex: 1;
// when updating font settings in the "Preferences -> Terminal" cluster's iframe
// must be accessible in DOM (e.g. elem.getBoundingClientRect() must work)
&.hidden {
opacity: 0;
pointer-events: none;
}
}
}
}
@@ -64,16 +64,30 @@ export class Terminal {
}
}
constructor(protected readonly dependencies: TerminalDependencies, { tabId, api }: TerminalArguments) {
get fontFamily() {
return this.dependencies.terminalConfig.get().fontFamily;
}
get fontSize() {
return this.dependencies.terminalConfig.get().fontSize;
}
get theme(): Record<string/*paramName*/, string/*color*/> {
return this.dependencies.themeStore.xtermColors;
}
constructor(protected readonly dependencies: TerminalDependencies, {
tabId,
api,
}: TerminalArguments) {
this.tabId = tabId;
this.api = api;
const { fontSize, fontFamily } = this.dependencies.terminalConfig.get();
this.xterm = new XTerm({
cursorBlink: true,
cursorStyle: "bar",
fontSize,
fontFamily,
fontSize: this.fontSize,
fontFamily: this.fontFamily,
});
// enable terminal addons
this.xterm.loadAddon(this.fitAddon);
@@ -95,17 +109,11 @@ export class Terminal {
window.addEventListener("resize", this.onResize);
this.disposer.push(
reaction(() => this.dependencies.themeStore.xtermColors, colors => {
this.xterm?.setOption("theme", colors);
}, {
fireImmediately: true,
}),
reaction(() => this.dependencies.terminalConfig.get().fontSize, this.setFontSize, {
fireImmediately: true,
}),
reaction(() => this.dependencies.terminalConfig.get().fontFamily, this.setFontFamily, {
reaction(() => this.theme, colors => this.xterm.setOption("theme", colors), {
fireImmediately: true,
}),
reaction(() => this.fontSize, this.setFontSize, { fireImmediately: true }),
reaction(() => this.fontFamily, this.setFontFamily, { fireImmediately: true }),
() => onDataHandler.dispose(),
() => this.fitAddon.dispose(),
() => this.api.removeAllListeners(),
@@ -120,15 +128,14 @@ export class Terminal {
}
fit = () => {
// Since this function is debounced we need to read this value as late as possible
if (!this.xterm) {
return;
}
try {
this.fitAddon.fit();
const { cols, rows } = this.xterm;
const { cols, rows } = this.fitAddon.proposeDimensions();
// attempt to resize/fit terminal when it's not visible in DOM will crash with exception
// see: https://github.com/xtermjs/xterm.js/issues/3118
if (isNaN(cols) || isNaN(rows)) return;
this.fitAddon.fit();
this.api.sendTerminalSize(cols, rows);
} catch (error) {
// see https://github.com/lensapp/lens/issues/1891
@@ -197,12 +204,21 @@ export class Terminal {
}
};
setFontSize = (size: number) => {
this.xterm.options.fontSize = size;
setFontSize = (fontSize: number) => {
logger.info(`[TERMINAL]: set fontSize to ${fontSize}`);
this.xterm.options.fontSize = fontSize;
this.fit();
};
setFontFamily = (family: string) => {
this.xterm.options.fontFamily = family;
setFontFamily = (fontFamily: string) => {
logger.info(`[TERMINAL]: set fontFamily to ${fontFamily}`);
this.xterm.options.fontFamily = fontFamily;
this.fit();
// provide css-variable within `:root {}`
document.documentElement.style.setProperty("--font-terminal", fontFamily);
};
keyHandler = (evt: KeyboardEvent): boolean => {
+82 -17
View File
@@ -3,34 +3,99 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Custom fonts
@import "~typeface-roboto/index.css";
// App's main font
// Downloaded from: https://fonts.google.com/specimen/Roboto
@font-face {
font-family: "Roboto";
src: url("../fonts/Roboto-Light.ttf") format("truetype");
font-display: swap;
font-weight: 300; // "light"
}
// Material Design Icons, used primarily in icon.tsx
// Latest: https://github.com/google/material-design-icons/tree/master/font
@font-face {
font-family: "Roboto";
src: url("../fonts/Roboto-LightItalic.ttf") format("truetype");
font-display: swap;
font-weight: 300; // "light" + italic
font-style: italic;
}
@font-face {
font-family: "Roboto";
src: url("../fonts/Roboto-Regular.ttf") format("truetype");
font-display: swap;
font-weight: 400; // "normal"
}
@font-face {
font-family: "Roboto";
src: url("../fonts/Roboto-Bold.ttf") format("truetype");
font-display: swap;
font-weight: 700; // "bold"
}
// Icon fonts, see: `icon.tsx`
// Latest version for manual update: https://github.com/google/material-design-icons/tree/master/font
@font-face {
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("./fonts/MaterialIcons-Regular.ttf") format("truetype");
src: url("../fonts/MaterialIcons-Regular.ttf") format("truetype");
}
// Terminal fonts (monospaced)
// Source: https://fonts.google.com/?category=Monospace
@font-face {
font-family: "Anonymous Pro";
src: local("Anonymous Pro"), url("../fonts/AnonymousPro-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "IBM Plex Mono";
src: local("IBM Plex Mono"), url("../fonts/IBMPlexMono-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "JetBrains Mono";
src: local("JetBrains Mono"), url("../fonts/JetBrainsMono-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Red Hat Mono";
src: local("Red Hat Mono"), url("../fonts/RedHatMono-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Source Code Pro";
src: local("Source Code Pro"), url("../fonts/SourceCodePro-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Space Mono";
src: local("Space Mono"), url("../fonts/SpaceMono-Regular.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Ubuntu Mono";
src: local("Ubuntu Mono"), url("../fonts/UbuntuMono-Regular.ttf") format("truetype");
font-display: block;
}
// Patched RobotoMono font with icons
// RobotoMono Windows Compatible for using in terminal
// https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/RobotoMono
@font-face {
font-family: 'RobotoMono';
font-family: "RobotoMono";
src: local("RobotoMono"), url("../fonts/Roboto-Mono-nerd.ttf") format("truetype");
font-display: block;
src: local('RobotoMono'), url('./fonts/roboto-mono-nerd.ttf') format('truetype');
}
#fonts-preloading {
> .icons {
@include font-preload("Material Icons");
}
> .terminal {
@include font-preload("RobotoMono");
}
}
Binary file not shown.
+24 -19
View File
@@ -14,7 +14,6 @@ import { Tooltip } from "../tooltip";
import * as Validators from "./input_validators";
import type { InputValidator } from "./input_validators";
import isFunction from "lodash/isFunction";
import isBoolean from "lodash/isBoolean";
import uniqueId from "lodash/uniqueId";
import { debounce } from "lodash";
@@ -24,7 +23,10 @@ export { InputValidators };
export type { InputValidator };
type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<HTMLInputElement> & TextareaHTMLAttributes<HTMLTextAreaElement> & DOMAttributes<InputElement>;
type InputElementProps =
InputHTMLAttributes<HTMLInputElement>
& TextareaHTMLAttributes<HTMLTextAreaElement>
& DOMAttributes<InputElement>;
export interface IconDataFnArg {
isDirty: boolean;
@@ -173,22 +175,18 @@ export class Input extends React.Component<InputProps, State> {
error => this.getValidatorError(value, validator) || error,
),
);
} else {
if (!validator.validate(value, this.props)) {
errors.push(this.getValidatorError(value, validator));
}
}
const result = validator.validate(value, this.props);
const isValid = validator.validate(value, this.props);
if (isBoolean(result) && !result) {
if (isValid === false) {
errors.push(this.getValidatorError(value, validator));
} else if (result instanceof Promise) {
} else if (isValid instanceof Promise) {
if (!validationId) {
this.validationId = validationId = uniqueId("validation_id_");
}
asyncValidators.push(
result.then(
isValid.then(
() => null, // don't consider any valid result from promise since we interested in errors only
error => this.getValidatorError(value, validator) || error,
),
@@ -266,17 +264,25 @@ export class Input extends React.Component<InputProps, State> {
setDirtyOnChange = debounce(() => this.setDirty(), 500);
onChange(evt: React.ChangeEvent<any>) {
this.props.onChange?.(evt.currentTarget.value, evt);
this.validate();
async onChange(evt: React.ChangeEvent<any>) {
const newValue = evt.currentTarget.value;
const eventCopy = { ...evt };
this.autoFitHeight();
this.setDirtyOnChange();
// re-render component when used as uncontrolled input
// when used @defaultValue instead of @value changing real input.value doesn't call render()
if (this.isUncontrolled && this.showMaxLenIndicator) {
this.forceUpdate();
// Handle uncontrolled components (`props.defaultValue` must be used instead `value`)
if (this.isUncontrolled) {
// update DOM since render() is not called on input's changes with uncontrolled inputs
if (this.showMaxLenIndicator) this.forceUpdate();
// don't propagate changes for invalid values
await this.validate();
if (!this.state.valid) return; // skip
}
// emit new value update
this.props.onChange?.(newValue, eventCopy);
}
onKeyDown(evt: React.KeyboardEvent<InputElement>) {
@@ -299,7 +305,7 @@ export class Input extends React.Component<InputProps, State> {
this.setDirty();
}
if(this.props.blurOnEnter){
if (this.props.blurOnEnter) {
//pressing enter indicates that the edit is complete, we can unfocus now
this.blur();
}
@@ -379,7 +385,6 @@ export class Input extends React.Component<InputProps, State> {
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
dirty: _dirty, // excluded from passing to input-element
defaultValue,
trim,
blurOnEnter,
...inputProps
@@ -8,7 +8,8 @@ import type { ReactNode } from "react";
import fse from "fs-extra";
import { TypedRegEx } from "typed-regex";
export class AsyncInputValidationError extends Error {}
export class AsyncInputValidationError extends Error {
}
export type InputValidator<IsAsync extends boolean> = {
/**
@@ -32,7 +33,7 @@ export type InputValidator<IsAsync extends boolean> = {
message?: undefined;
debounce: number;
}
);
);
export function inputValidator<IsAsync extends boolean = false>(validator: InputValidator<IsAsync>): InputValidator<IsAsync> {
return validator;
@@ -52,7 +53,14 @@ export const isEmail = inputValidator({
export const isNumber = inputValidator({
condition: ({ type }) => type === "number",
message: () => `Invalid number`,
message(value, { min, max }) {
const minMax: string = [
typeof min === "number" ? `min: ${min}` : undefined,
typeof max === "number" ? `max: ${max}` : undefined,
].filter(Boolean).join(", ");
return `Invalid number${minMax ? ` (${minMax})` : ""}`;
},
validate: (value, { min, max }) => {
const numVal = +value;
-17
View File
@@ -59,20 +59,3 @@
@content; // css-modules (*.module.scss)
}
}
// Makes custom @font-family available at earlier stages.
// Element must exist in DOM as soon as possible to initiate preloading.
@mixin font-preload($fontFamily) {
position: absolute;
visibility: hidden;
height: 0;
&:before {
width: 0;
display: block;
overflow: hidden;
content: "x"; // some text required to start applying font in document
font-family: $fontFamily; // imported name in @font-face declaration
@content;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const { Menu } = components;
export interface SelectOption<Value> {
value: Value;
label: string;
label: React.ReactNode;
isDisabled?: boolean;
isSelected?: boolean;
}