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

Adding keyboard settings with localectl #561

Merged
merged 3 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/actions/localization-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

import {
getCommonLocales,
getCompositorSelectedLayout,
getLanguage,
getLanguageData,
getLanguages,
getLocaleData,
getLocaleKeyboardLayouts,
getLocales,
} from "../apis/localization.js";

Expand Down Expand Up @@ -69,3 +71,15 @@ export const getCommonLocalesAction = () => {
});
};
};

export const getKeyboardLayoutsAction = ({ language }) => {
return async (dispatch) => {
const keyboardLayouts = await getLocaleKeyboardLayouts({ locale: language });
const compositorSelectedLayout = await getCompositorSelectedLayout();

dispatch({
payload: { compositorSelectedLayout, keyboardLayouts },
type: "GET_KEYBOARD_LAYOUTS"
});
};
};
43 changes: 40 additions & 3 deletions src/apis/localization.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import cockpit from "cockpit";

import { getLanguageAction, getLanguagesAction } from "../actions/localization-actions.js";
import { getKeyboardLayoutsAction, getLanguageAction, getLanguagesAction } from "../actions/localization-actions.js";

import { debug } from "../helpers/log.js";
import { _callClient, _getProperty, _setProperty } from "./helpers.js";
Expand Down Expand Up @@ -62,18 +62,25 @@ export class LocalizationClient {
}

async initData () {
const language = await getLanguage();
await this.dispatch(getLanguageAction());
await this.dispatch(getLanguagesAction());
await this.dispatch(getKeyboardLayoutsAction({ language }));
}

startEventMonitor () {
this.client.subscribe(
{ },
(path, iface, signal, args) => {
async (path, iface, signal, args) => {
switch (signal) {
case "CompositorSelectedLayoutChanged":
await this.dispatch(getKeyboardLayoutsAction({ language: await getLanguage() }));
break;
case "PropertiesChanged":
if (args[0] === INTERFACE_NAME && Object.hasOwn(args[1], "Language")) {
this.dispatch(getLanguageAction());
await this.dispatch(getLanguageAction());
const language = await getLanguage();
await this.dispatch(getKeyboardLayoutsAction({ language }));
} else {
debug(`Unhandled signal on ${path}: ${iface}.${signal}`, JSON.stringify(args));
}
Expand Down Expand Up @@ -139,3 +146,33 @@ export const getLocaleData = ({ locale }) => {
export const setLanguage = ({ lang }) => {
return setProperty("Language", cockpit.variant("s", lang));
};

export const getCompositorSelectedLayout = () => {
return callClient("GetCompositorSelectedLayout");
};

export const getConfiguredLayouts = () => {
return callClient("GetConfiguredLayouts");
};

export const setCompositorLayouts = ({ layouts }) => {
return callClient("SetCompositorLayouts", [layouts, []]);
};

/**
* @param {string} lang Locale id
*
* @returns {Promise} Resolves a list of locale keyboards
*/
export const getLocaleKeyboardLayouts = async ({ locale }) => {
// FIXME: Remove this try-catch when the method is available in the backend
// in all supported versions
let keyboards;

try {
keyboards = await callClient("GetLocaleKeyboardLayouts", [locale]);
} catch (e) {
keyboards = [];
}
return keyboards;
};
23 changes: 14 additions & 9 deletions src/components/localization/InstallationLanguage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
Title,
} from "@patternfly/react-core";
import { SearchIcon, TimesIcon } from "@patternfly/react-icons";

Expand All @@ -47,6 +46,8 @@ import {

import { LanguageContext } from "../../contexts/Common.jsx";

import { KeyboardSelector } from "./Keyboard.jsx";

import "./InstallationLanguage.scss";

const _ = cockpit.gettext;
Expand Down Expand Up @@ -295,21 +296,16 @@ class LanguageSelector extends React.Component {
}

const InstallationLanguage = ({ setIsFormValid, setStepNotification }) => {
const { commonLocales, language, languages } = useContext(LanguageContext);
const { commonLocales, keyboardLayouts, language, languages } = useContext(LanguageContext);

useEffect(() => {
setIsFormValid(language !== "");
}, [language, setIsFormValid]);

return (
<>
<Title
headingLevel="h3"
>
{_("Choose a language")}
</Title>
<Form>
<FormGroup>
<Form className="anaconda-screen-selectors-container" isHorizontal>
<FormGroup label={_("Language")}>
<LanguageSelector
id="language-selector"
languages={languages}
Expand All @@ -320,6 +316,15 @@ const InstallationLanguage = ({ setIsFormValid, setStepNotification }) => {
reRenderApp={setLanguage}
/>
</FormGroup>

{keyboardLayouts.length > 0 && (
<FormGroup label={_("Keyboard")} fieldId={`${SCREEN_ID}-keyboard-layouts`}>
<KeyboardSelector
id="keyboard-selector"
idPrefix={SCREEN_ID}
/>
</FormGroup>
)}
</Form>
</>
);
Expand Down
9 changes: 6 additions & 3 deletions src/components/localization/InstallationLanguage.scss
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
.anaconda-screen-selectors-container {
max-width: 33rem;
}

.anaconda-screen-language-menu.pf-v5-c-menu.pf-m-scrollable {
max-width: 400px;
// heading: 84, footer: 44, content (about from header): 158, necessary padding underneath: 8px,
// keyboardSelector: 60
// 50px magic number
--pf-v5-c-menu__content--MaxHeight: calc(100vh - 84px - 44px - 158px - 8px - 50px);
--pf-v5-c-menu__content--MaxHeight: calc(100vh - 84px - 44px - 158px - 8px - 60px - 50px);
}

.anaconda-screen-language-search {
margin-bottom: var(--pf-v5-global--spacer--sm);
max-width: 400px;
}

.anaconda-screen-language-menu {
Expand Down
66 changes: 66 additions & 0 deletions src/components/localization/Keyboard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2024 Red Hat, Inc.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with This program; If not, see <http://www.gnu.org/licenses/>.
*/

import cockpit from "cockpit";

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

import { setCompositorLayouts } from "../../apis/localization.js";

import { LanguageContext } from "../../contexts/Common.jsx";

const _ = cockpit.gettext;

export const KeyboardSelector = ({ idPrefix }) => {
const { compositorSelectedLayout, keyboardLayouts } = useContext(LanguageContext);
const keyboards = keyboardLayouts;

useEffect(() => {
if (compositorSelectedLayout && keyboards.find(({ "layout-id": layoutId }) => layoutId?.v === compositorSelectedLayout)) {
return;
}
if (keyboards.length > 0) {
setCompositorLayouts({ layouts: [keyboards[0]["layout-id"]?.v] }); // Default layout without variant
}
}, [keyboards, compositorSelectedLayout]);

const handleChange = (event) => {
const { value } = event.target;

setCompositorLayouts({ layouts: [value] });
};

const selectedValue =
compositorSelectedLayout || (keyboards.length > 0 ? keyboards[0]["layout-id"]?.v : "");

return (
<FormSelect
id={`${idPrefix}-keyboard-layouts`}
onChange={handleChange}
value={selectedValue}
>
{keyboards.map(({ description, "layout-id": layoutId }) => (
<FormSelectOption
key={layoutId?.v}
label={description?.v || _("Unknown layout")}
value={layoutId?.v}
/>
))}
</FormSelect>
);
};
8 changes: 8 additions & 0 deletions src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const storageInitialState = {
/* Initial state for the localization store substate */
export const localizationInitialState = {
commonLocales: [],
compositorLayouts: [],
keyboardLayouts: [],
language: "",
languages: {}
};
Expand Down Expand Up @@ -173,6 +175,12 @@ export const localizationReducer = (state = localizationInitialState, action) =>
return { ...state, commonLocales: action.payload.commonLocales };
} else if (action.type === "GET_LANGUAGE") {
return { ...state, language: action.payload.language };
} else if (action.type === "GET_KEYBOARD_LAYOUTS") {
return {
...state,
compositorSelectedLayout: action.payload.compositorSelectedLayout,
keyboardLayouts: action.payload.keyboardLayouts,
};
} else {
return state;
}
Expand Down
1 change: 1 addition & 0 deletions test/anacondalib.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def resetLanguage(self):
lang = Language(b, m)
lang.dbus_set_language("en_US.UTF-8")
lang.dbus_set_locale("en_US.UTF-8")
lang.dbus_set_compositor_layouts(["us"])

def resetUsers(self):
m = self.machine
Expand Down
38 changes: 35 additions & 3 deletions test/check-language
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ from installer import Installer
from language import Language
from review import Review
from storage import Storage
from testlib import nondestructive, test_main # pylint: disable=import-error
from testlib import nondestructive, skipImage, test_main # pylint: disable=import-error


@nondestructive
Expand All @@ -45,7 +45,7 @@ class TestLanguage(VirtInstallMachineCase):

# Expect the backend set language to be preselected and the WebUI translated
lang.check_selected_locale("de_DE")
b.wait_in_text("h2 + h3", "Wählen Sie eine Sprache aus")
b.wait_in_text("h2", "Willkommen. Lassen Sie uns nun Fedora installieren.")

def testBackendTranslations(self):
b = self.browser
Expand Down Expand Up @@ -129,7 +129,7 @@ class TestLanguage(VirtInstallMachineCase):
ignore=[*pixel_tests_ignore, ".pf-v5-c-menu__item-text"],
)

b.wait_in_text("h2 + h3", "Wählen Sie eine Sprache aus")
b.wait_in_text("h2", "Willkommen. Lassen Sie uns nun Fedora installieren.")
# TODO: Add checks for plural translations when these are present

# Check that the language is updated in the backend
Expand Down Expand Up @@ -168,6 +168,38 @@ class TestLanguage(VirtInstallMachineCase):
# test the macedonian language selected
r.check_language("mk_MK.UTF-8")

@skipImage("TODO: Keyboard feature not merged yet", "fedora-42-boot")
def testKeyboardLayoutSelection(self):
b = self.browser
m = self.machine
i = Installer(b, m)
l = Language(b, m)

l.dbus_set_language("en_US.UTF-8")
i.open()

l.check_selected_keyboard("us")
l.check_selected_keyboard_on_device("us")

l.select_locale("de_DE")
l.check_selected_locale("de_DE")

l.check_selected_keyboard_on_device("de", expected_variant="nodeadkeys")

l.select_locale("cs_CZ", is_common=False)
l.check_selected_locale("cs_CZ", is_common=False)

l.check_selected_keyboard("cz")
l.check_selected_keyboard_on_device("cz")

l.select_keyboard_layout("cz (qwerty)")
l.check_selected_keyboard_on_device("cz", "qwerty")

l.select_locale("en_US")
l.check_selected_locale("en_US")
l.check_selected_keyboard("us")
l.check_selected_keyboard_on_device("us")


if __name__ == '__main__':
test_main()
32 changes: 32 additions & 0 deletions test/helpers/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from step_logger import log_step
from steps import LANGUAGE

LOCALIZATION_SERVICE = "org.fedoraproject.Anaconda.Modules.Localization"
LOCALIZATION_INTERFACE = "org.fedoraproject.Anaconda.Modules.Localization"
LOCALIZATION_OBJECT_PATH = "/org/fedoraproject/Anaconda/Modules/Localization"

Expand Down Expand Up @@ -85,3 +86,34 @@ def dbus_set_locale(self, value):
{BOSS_SERVICE} \
{BOSS_OBJECT_PATH} \
{BOSS_INTERFACE} SetLocale s "{value}"')

def dbus_set_compositor_layouts(self, layouts):
self.machine.execute(f"busctl --address='{self._bus_address}' \
call \
{LOCALIZATION_SERVICE} \
{LOCALIZATION_OBJECT_PATH} \
{LOCALIZATION_INTERFACE} SetCompositorLayouts asas 1 '{layouts[0]}' 0")

def select_keyboard_layout(self, layout):
self.browser.select_from_dropdown(".anaconda-screen-selectors-container select", layout)

def check_selected_keyboard(self, layout):
self.browser.wait_val(".anaconda-screen-selectors-container select", layout)

def check_selected_keyboard_on_device(self, expected_layout, expected_variant=None):
result = self.machine.execute("localectl status")
layout = None
variant = None

for line in result.splitlines():
if "X11 Layout" in line:
layout = line.split(":")[-1].strip()
if "X11 Variant" in line:
variant = line.split(":")[-1].strip()

assert layout == expected_layout, f"Expected layout '{expected_layout}', but got '{layout}'"

if expected_variant:
assert variant == expected_variant, f"Expected variant '{expected_variant}', but got '{variant}'"
else:
assert not variant, f"Expected no variant, but got '{variant}'"
4 changes: 4 additions & 0 deletions test/helpers/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@

HELPERS_DIR = os.path.dirname(__file__)
sys.path.append(HELPERS_DIR)
BOTS_DIR = f'{HELPERS_DIR}/../../bots'
sys.path.append(BOTS_DIR)



from step_logger import log_step
from steps import CUSTOM_MOUNT_POINT, INSTALLATION_METHOD
Expand Down
Loading