Skip to content

Commit

Permalink
Merge pull request #917 from openSUSE/installer-keymap
Browse files Browse the repository at this point in the history
Configure installer keymap
  • Loading branch information
lslezak authored Dec 14, 2023
2 parents 7c8e17f + a0be520 commit bdb04d9
Show file tree
Hide file tree
Showing 14 changed files with 319 additions and 15 deletions.
12 changes: 12 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ Where `COCKPIT_TARGET` is the IP address or hostname of the running Agama
instance. This is especially useful if you use the Live ISO which does not contain
any development tools, you can develop the web frontend easily from your workstation.

### Special Environment Variables

`COCKPIT_TARGET` - When running the development server set up a proxy to the
specified Cockpit server. See the [using a development
server](#using-a-development-server) section above.

`LOCAL_CONNECTION` - Force behaving as in a local connection, useful for
development or testing some Agama features. For example the keyboard layout
switcher is displayed only in local installation because it cannot work in
remote connection. This option will force displaying it even in a remote
connection.

## JSDoc Documentation

This project uses [TypeDoc](https://typedoc.org/) to generate the API documentation. The `jsdoc`
Expand Down
7 changes: 5 additions & 2 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
ShowTerminalButton,
Sidebar
} from "~/components/core";
import { LanguageSwitcher } from "./components/l10n";
import { InstallerKeymapSwitcher, InstallerLocaleSwitcher } from "./components/l10n";
import { Layout, Loading, Title } from "./components/layout";
import { useInstallerL10n } from "./context/installerL10n";

Expand Down Expand Up @@ -116,7 +116,10 @@ function App() {
</div>
<div className="full-width highlighted">
<div className="flex-stack">
<LanguageSwitcher />
<div className="locale-container">
<div><InstallerLocaleSwitcher /></div>
<div><InstallerKeymapSwitcher /></div>
</div>
</div>
</div>
</Sidebar>
Expand Down
7 changes: 7 additions & 0 deletions web/src/assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,10 @@ button.kebab-toggler {
.pattern-group-name {
font-size: 120%;
}

.locale-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1em;
width: 100%;
}
2 changes: 1 addition & 1 deletion web/src/components/core/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default function Sidebar ({ children }) {
<header className="split justify-between">
<h2>
{/* TRANSLATORS: sidebar header */}
{_("Options")}
{_("Installer Options")}
</h2>

<button
Expand Down
75 changes: 75 additions & 0 deletions web/src/components/l10n/InstallerKeymapSwitcher.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";

import cockpit from "../../lib/cockpit";

import { Icon } from "~/components/layout";
import { _ } from "~/i18n";
import { useInstallerL10n } from "~/context/installerL10n";
import { useL10n } from "~/context/l10n";
import { localConnection } from "~/utils";
import { If } from "~/components/core";

const sort = (keymaps) => {
// sort the keymap names using the current locale
const lang = cockpit.language || "en";
return keymaps.sort((k1, k2) => k1.name.localeCompare(k2.name, lang));
};

export default function InstallerKeymapSwitcher() {
const { keymap, changeKeymap } = useInstallerL10n();
const { keymaps } = useL10n();

const onChange = (_, id) => changeKeymap(id);

const options = sort(keymaps)
.map((keymap, index) => <FormSelectOption key={index} value={keymap.id} label={keymap.name} />);

return (
<>
<h3>
{/* TRANSLATORS: label for keyboard layout selection */}
<Icon name="keyboard" size="24" />{_("Keyboard")}
</h3>
<If
condition={localConnection()}
then={
<FormSelect
id="keyboard"
// TRANSLATORS: label for keyboard layout selection
aria-label={_("keyboard")}
value={keymap}
onChange={onChange}
>
{options}
</FormSelect>
}
else={
// TRANSLATORS:
_("Keyboard layout cannot be changed in remote installation")
}
/>
</>
);
}
71 changes: 71 additions & 0 deletions web/src/components/l10n/InstallerKeymapSwitcher.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) [2023] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";

import InstallerKeymapSwitcher from "./InstallerKeymapSwitcher";

let mockChangeKeyboardFn;

jest.mock("~/lib/cockpit", () => ({
gettext: term => term,
}));

jest.mock("~/context/installerL10n", () => ({
...jest.requireActual("~/context/installerL10n"),
useInstallerL10n: () => ({
changeKeymap: mockChangeKeyboardFn,
keymap: "us"
})
}));

jest.mock("~/context/l10n", () => ({
...jest.requireActual("~/context/l10n"),
useL10n: () => ({
keymaps: [
{ id: "cz", name: "Czech" },
{ id: "cz(qwerty)", name: "Czech (QWERTY)" },
{ id: "de", name: "German" },
{ id: "us", name: "English (US)" },
{ id: "us(dvorak)", name: "English (Dvorak)" }
]
})
}));

beforeEach(() => {
mockChangeKeyboardFn = jest.fn();
});

it("InstallerKeymapSwitcher", async () => {
const { user } = plainRender(<InstallerKeymapSwitcher />);

// the current keyboard is correctly selected
expect(screen.getByRole("option", { name: "English (US)" }).selected).toBe(true);

// change the keyboard
await user.selectOptions(
screen.getByRole("combobox", { label: "Keyboard" }),
screen.getByRole("option", { name: "Czech" })
);
expect(mockChangeKeyboardFn).toHaveBeenCalledWith("cz");
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
*/

import React, { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { FormSelect, FormSelectOption, Popover } from "@patternfly/react-core";

import { Icon } from "../layout";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";
import { _ } from "~/i18n";
import { useInstallerL10n } from "~/context/installerL10n";
import cockpit from "~/lib/cockpit";

export default function LanguageSwitcher() {
export default function InstallerLocaleSwitcher() {
const { language, changeLanguage } = useInstallerL10n();
const [selected, setSelected] = useState(null);
const languages = cockpit.manifests.agama?.locales || [];
Expand All @@ -40,10 +42,32 @@ export default function LanguageSwitcher() {
const options = Object.keys(languages).sort()
.map(id => <FormSelectOption key={id} value={id} label={languages[id]} />);

// TRANSLATORS: help text for the language selector in the sidebar,
// %s will be replaced by the "Localization" page link
const [msg1, msg2] = _("The language used by the installer. The language \
for the installed system can be set in the %s page.").split("%s");

// "hide" is a function which closes the popover
const description = (hide) => (
<>
{msg1}
{/* close the popover after clicking the link */}
<Link to="/l10n" onClick={hide}>
{_("Localization")}
</Link>
{msg2}
</>
);

return (
<>
<h3>
<Icon name="translate" size="24" />{_("Display Language")}
<Icon name="translate" size="24" />
{_("Language")}&nbsp;
{/* smaller width of the popover so it does not overflow outside the sidebar */}
<Popover showClose={false} bodyContent={description} maxWidth="15em">
<Icon name="info" size="16" />
</Popover>
</h3>
<FormSelect
id="language"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import LanguageSwitcher from "./LanguageSwitcher";
import InstallerLocaleSwitcher from "./InstallerLocaleSwitcher";

const mockLanguage = "es-es";
let mockChangeLanguageFn;
Expand Down Expand Up @@ -52,8 +52,8 @@ beforeEach(() => {
mockChangeLanguageFn = jest.fn();
});

it("LanguageSwitcher", async () => {
const { user } = plainRender(<LanguageSwitcher />);
it("InstallerLocaleSwitcher", async () => {
const { user } = plainRender(<InstallerLocaleSwitcher />);
expect(screen.getByRole("option", { name: "Español" }).selected).toBe(true);
await user.selectOptions(
screen.getByRole("combobox", { label: "Display Language" }),
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/l10n/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022] SUSE LLC
* Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -19,8 +19,9 @@
* find current contact information at www.suse.com.
*/

export { default as InstallerKeymapSwitcher } from "./InstallerKeymapSwitcher";
export { default as InstallerLocaleSwitcher } from "./InstallerLocaleSwitcher";
export { default as KeymapSelector } from "./KeymapSelector";
export { default as L10nPage } from "./L10nPage";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as LocaleSelector } from "./LocaleSelector";
export { default as TimezoneSelector } from "./TimezoneSelector";
30 changes: 29 additions & 1 deletion web/src/context/installerL10n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* find current contact information at www.suse.com.
*/

// cspell:ignore localectl setxkbmap xorg
// @ts-check

import React, { useCallback, useEffect, useState } from "react";
Expand Down Expand Up @@ -178,6 +179,17 @@ function reload(newLanguage) {
}
}

/**
* Extracts the keymap from the `setxkbmap -query` output.
*
* @param {string} output
* @returns {string|undefined}
*/
function keymapFromX(output) {
const matcher = /^layout:\s+(\S.*)$/m;
return matcher.exec(output)?.at(1);
}

/**
* This provider sets the installer locale. By default, it uses the URL "lang" query parameter or
* the preferred locale from the browser and synchronizes the UI and the backend locales. To
Expand All @@ -196,6 +208,7 @@ function reload(newLanguage) {
function InstallerL10nProvider({ children }) {
const client = useInstallerClient();
const [language, setLanguage] = useState(undefined);
const [keymap, setKeymap] = useState(undefined);
const [backendPending, setBackendPending] = useState(false);
const { cancellablePromise } = useCancellablePromise();

Expand Down Expand Up @@ -240,6 +253,15 @@ function InstallerL10nProvider({ children }) {
}
}, [storeInstallerLanguage, setLanguage]);

const changeKeymap = useCallback(async (id) => {
setKeymap(id);
// write the config to file (/etc/X11/xorg.conf.d/00-keyboard.conf),
// this also sets the console keyboard!
await cockpit.spawn(["localectl", "set-x11-keymap", id]);
// set the current X11 keyboard
await cockpit.spawn(["setxkbmap", id], { environ: ["DISPLAY=:0"] });
}, [setKeymap]);

useEffect(() => {
if (!language) changeLanguage();
}, [changeLanguage, language]);
Expand All @@ -251,8 +273,14 @@ function InstallerL10nProvider({ children }) {
setBackendPending(false);
}, [client, language, backendPending, storeInstallerLanguage]);

useEffect(() => {
cockpit.spawn(["setxkbmap", "-query"], { environ: ["DISPLAY=:0"] }).then(output => setKeymap(keymapFromX(output)));
}, [setKeymap]);

const value = { language, changeLanguage, keymap, changeKeymap };

return (
<L10nContext.Provider value={{ language, changeLanguage }}>{children}</L10nContext.Provider>
<L10nContext.Provider value={value}>{children}</L10nContext.Provider>
);
}

Expand Down
3 changes: 2 additions & 1 deletion web/src/context/installerL10n.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ jest.mock("~/lib/cockpit", () => ({
"es-es": "Español"
}
}
}
},
spawn: jest.fn().mockResolvedValue()
}));

// Helper component that displays a translated message depending on the
Expand Down
Loading

0 comments on commit bdb04d9

Please sign in to comment.