1 /* === This file is part of Calamares - <https://calamares.io> ===
2 *
3 * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot <groot@kde.org>
4 * SPDX-FileCopyrightText: 2020 Camilo Higuita <milo.h@aol.com> *
5 * SPDX-License-Identifier: GPL-3.0-or-later
6 *
7 * Calamares is Free Software: see the License-Identifier above.
8 *
9 */
10
11 #include "Config.h"
12
13 #include "SetKeyboardLayoutJob.h"
14 #include "keyboardwidget/keyboardpreview.h"
15
16 #include "GlobalStorage.h"
17 #include "JobQueue.h"
18 #include "utils/Logger.h"
19 #include "utils/RAII.h"
20 #include "utils/Retranslator.h"
21 #include "utils/String.h"
22 #include "utils/Variant.h"
23
24 #include <QApplication>
25 #include <QProcess>
26 #include <QTimer>
27
28 /* Returns stringlist with suitable setxkbmap command-line arguments
29 * to set the given @p model.
30 */
31 static inline QStringList
xkbmap_model_args(const QString & model)32 xkbmap_model_args( const QString& model )
33 {
34 QStringList r { "-model", model };
35 return r;
36 }
37
38
39 /* Returns stringlist with suitable setxkbmap command-line arguments
40 * to set the given @p layout and @p variant.
41 */
42 static inline QStringList
xkbmap_layout_args(const QString & layout,const QString & variant)43 xkbmap_layout_args( const QString& layout, const QString& variant )
44 {
45 QStringList r { "-layout", layout };
46 if ( !variant.isEmpty() )
47 {
48 r << "-variant" << variant;
49 }
50 return r;
51 }
52
53 static inline QStringList
xkbmap_layout_args(const QStringList & layouts,const QStringList & variants,const QString & switchOption="grp:alt_shift_toggle")54 xkbmap_layout_args( const QStringList& layouts,
55 const QStringList& variants,
56 const QString& switchOption = "grp:alt_shift_toggle" )
57 {
58 if ( layouts.size() != variants.size() )
59 {
60 cError() << "Number of layouts and variants must be equal (empty string should be used if there is no "
61 "corresponding variant)";
62 return QStringList();
63 }
64
65 QStringList r { "-layout", layouts.join( "," ) };
66
67 if ( !variants.isEmpty() )
68 {
69 r << "-variant" << variants.join( "," );
70 }
71
72 if ( !switchOption.isEmpty() )
73 {
74 r << "-option" << switchOption;
75 }
76
77 return r;
78 }
79
80 /* Returns group-switch setxkbd option if set
81 * or an empty string otherwise
82 */
83 static inline QString
xkbmap_query_grp_option()84 xkbmap_query_grp_option()
85 {
86 QProcess setxkbmapQuery;
87 setxkbmapQuery.start( "setxkbmap", { "-query" } );
88 setxkbmapQuery.waitForFinished();
89
90 QString outputLine;
91
92 do
93 {
94 outputLine = setxkbmapQuery.readLine();
95 } while ( setxkbmapQuery.canReadLine() && !outputLine.startsWith( "options:" ) );
96
97 if ( !outputLine.startsWith( "options:" ) )
98 {
99 return QString();
100 }
101
102 int index = outputLine.indexOf( "grp:" );
103
104 if ( index == -1 )
105 {
106 return QString();
107 }
108
109 //it's either in the end of line or before the other option so \s or ,
110 int lastIndex = outputLine.indexOf( QRegExp( "[\\s,]" ), index );
111
112 return outputLine.mid( index, lastIndex - index );
113 }
114
115 AdditionalLayoutInfo
getAdditionalLayoutInfo(const QString & layout)116 Config::getAdditionalLayoutInfo( const QString& layout )
117 {
118 QFile layoutTable( ":/non-ascii-layouts" );
119
120 if ( !layoutTable.open( QIODevice::ReadOnly | QIODevice::Text ) )
121 {
122 cError() << "Non-ASCII layout table could not be opened";
123 return AdditionalLayoutInfo();
124 }
125
126 QString tableLine;
127
128 do
129 {
130 tableLine = layoutTable.readLine();
131 } while ( layoutTable.canReadLine() && !tableLine.startsWith( layout ) );
132
133 if ( !tableLine.startsWith( layout ) )
134 {
135 return AdditionalLayoutInfo();
136 }
137
138 QStringList tableEntries = tableLine.split( " ", SplitSkipEmptyParts );
139
140 AdditionalLayoutInfo r;
141
142 r.additionalLayout = tableEntries[ 1 ];
143 r.additionalVariant = tableEntries[ 2 ] == "-" ? "" : tableEntries[ 2 ];
144
145 r.vconsoleKeymap = tableEntries[ 3 ];
146
147 return r;
148 }
149
Config(QObject * parent)150 Config::Config( QObject* parent )
151 : QObject( parent )
152 , m_keyboardModelsModel( new KeyboardModelsModel( this ) )
153 , m_keyboardLayoutsModel( new KeyboardLayoutModel( this ) )
154 , m_keyboardVariantsModel( new KeyboardVariantsModel( this ) )
155 {
156 m_setxkbmapTimer.setSingleShot( true );
157
158 // Connect signals and slots
159 connect( m_keyboardModelsModel, &KeyboardModelsModel::currentIndexChanged, [&]( int index ) {
160 // Set Xorg keyboard model
161 m_selectedModel = m_keyboardModelsModel->key( index );
162 QProcess::execute( "setxkbmap", xkbmap_model_args( m_selectedModel ) );
163 emit prettyStatusChanged();
164 } );
165
166 connect( m_keyboardLayoutsModel, &KeyboardLayoutModel::currentIndexChanged, [&]( int index ) {
167 m_selectedLayout = m_keyboardLayoutsModel->item( index ).first;
168 updateVariants( QPersistentModelIndex( m_keyboardLayoutsModel->index( index ) ) );
169 emit prettyStatusChanged();
170 } );
171
172 connect( m_keyboardVariantsModel, &KeyboardVariantsModel::currentIndexChanged, this, &Config::xkbChanged );
173
174 // If the user picks something explicitly -- not a consequence of
175 // a guess -- then move to UserSelected state and stay there.
176 connect( m_keyboardModelsModel, &KeyboardModelsModel::currentIndexChanged, this, &Config::selectionChange );
177 connect( m_keyboardLayoutsModel, &KeyboardLayoutModel::currentIndexChanged, this, &Config::selectionChange );
178 connect( m_keyboardVariantsModel, &KeyboardVariantsModel::currentIndexChanged, this, &Config::selectionChange );
179
180 m_selectedModel = m_keyboardModelsModel->key( m_keyboardModelsModel->currentIndex() );
181 m_selectedLayout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).first;
182 m_selectedVariant = m_keyboardVariantsModel->key( m_keyboardVariantsModel->currentIndex() );
183 }
184
185 void
xkbChanged(int index)186 Config::xkbChanged( int index )
187 {
188 // Set Xorg keyboard layout + variant
189 m_selectedVariant = m_keyboardVariantsModel->key( index );
190
191 if ( m_setxkbmapTimer.isActive() )
192 {
193 m_setxkbmapTimer.stop();
194 m_setxkbmapTimer.disconnect( this );
195 }
196
197 connect( &m_setxkbmapTimer, &QTimer::timeout, this, &Config::xkbApply );
198
199 m_setxkbmapTimer.start( QApplication::keyboardInputInterval() );
200 emit prettyStatusChanged();
201 }
202
203 void
xkbApply()204 Config::xkbApply()
205 {
206 m_additionalLayoutInfo = getAdditionalLayoutInfo( m_selectedLayout );
207
208 if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() )
209 {
210 m_additionalLayoutInfo.groupSwitcher = xkbmap_query_grp_option();
211
212 if ( m_additionalLayoutInfo.groupSwitcher.isEmpty() )
213 {
214 m_additionalLayoutInfo.groupSwitcher = "grp:alt_shift_toggle";
215 }
216
217 QProcess::execute( "setxkbmap",
218 xkbmap_layout_args( { m_additionalLayoutInfo.additionalLayout, m_selectedLayout },
219 { m_additionalLayoutInfo.additionalVariant, m_selectedVariant },
220 m_additionalLayoutInfo.groupSwitcher ) );
221
222
223 cDebug() << "xkbmap selection changed to: " << m_selectedLayout << '-' << m_selectedVariant << "(added "
224 << m_additionalLayoutInfo.additionalLayout << "-" << m_additionalLayoutInfo.additionalVariant
225 << " since current layout is not ASCII-capable)";
226 }
227 else
228 {
229 QProcess::execute( "setxkbmap", xkbmap_layout_args( m_selectedLayout, m_selectedVariant ) );
230 cDebug() << "xkbmap selection changed to: " << m_selectedLayout << '-' << m_selectedVariant;
231 }
232 m_setxkbmapTimer.disconnect( this );
233 }
234
235
236 KeyboardModelsModel*
keyboardModels() const237 Config::keyboardModels() const
238 {
239 return m_keyboardModelsModel;
240 }
241
242 KeyboardLayoutModel*
keyboardLayouts() const243 Config::keyboardLayouts() const
244 {
245 return m_keyboardLayoutsModel;
246 }
247
248 KeyboardVariantsModel*
keyboardVariants() const249 Config::keyboardVariants() const
250 {
251 return m_keyboardVariantsModel;
252 }
253
254 static QPersistentModelIndex
findLayout(const KeyboardLayoutModel * klm,const QString & currentLayout)255 findLayout( const KeyboardLayoutModel* klm, const QString& currentLayout )
256 {
257 QPersistentModelIndex currentLayoutItem;
258
259 for ( int i = 0; i < klm->rowCount(); ++i )
260 {
261 QModelIndex idx = klm->index( i );
262 if ( idx.isValid() && idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() == currentLayout )
263 {
264 currentLayoutItem = idx;
265 }
266 }
267
268 return currentLayoutItem;
269 }
270
271 void
detectCurrentKeyboardLayout()272 Config::detectCurrentKeyboardLayout()
273 {
274 if ( m_state != State::Initial )
275 {
276 return;
277 }
278 cScopedAssignment returnToIntial( &m_state, State::Initial );
279 m_state = State::Guessing;
280
281 //### Detect current keyboard layout and variant
282 QString currentLayout;
283 QString currentVariant;
284 QProcess process;
285 process.start( "setxkbmap", QStringList() << "-print" );
286
287 if ( process.waitForFinished() )
288 {
289 const QStringList list = QString( process.readAll() ).split( "\n", SplitSkipEmptyParts );
290
291 // A typical line looks like
292 // xkb_symbols { include "pc+latin+ru:2+inet(evdev)+group(alt_shift_toggle)+ctrl(swapcaps)" };
293 for ( const auto& line : list )
294 {
295 if ( !line.trimmed().startsWith( "xkb_symbols" ) )
296 {
297 continue;
298 }
299
300 int firstQuote = line.indexOf( '"' );
301 int lastQuote = line.lastIndexOf( '"' );
302
303 if ( firstQuote < 0 || lastQuote < 0 || lastQuote <= firstQuote )
304 {
305 continue;
306 }
307
308 QStringList split = line.mid( firstQuote + 1, lastQuote - firstQuote ).split( "+", SplitSkipEmptyParts );
309 cDebug() << split;
310 if ( split.size() >= 2 )
311 {
312 currentLayout = split.at( 1 );
313
314 if ( currentLayout.contains( "(" ) )
315 {
316 int parenthesisIndex = currentLayout.indexOf( "(" );
317 currentVariant = currentLayout.mid( parenthesisIndex + 1 ).trimmed();
318 currentVariant.chop( 1 );
319 currentLayout = currentLayout.mid( 0, parenthesisIndex ).trimmed();
320 }
321
322 break;
323 }
324 }
325 }
326
327 //### Layouts and Variants
328 QPersistentModelIndex currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout );
329 if ( !currentLayoutItem.isValid() && ( ( currentLayout == "latin" ) || ( currentLayout == "pc" ) ) )
330 {
331 currentLayout = "us";
332 currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout );
333 }
334
335 // Set current layout and variant
336 if ( currentLayoutItem.isValid() )
337 {
338 m_keyboardLayoutsModel->setCurrentIndex( currentLayoutItem.row() );
339 updateVariants( currentLayoutItem, currentVariant );
340 }
341
342 // Default to the first available layout if none was set
343 // Do this after unblocking signals so we get the default variant handling.
344 if ( !currentLayoutItem.isValid() && m_keyboardLayoutsModel->rowCount() > 0 )
345 {
346 m_keyboardLayoutsModel->setCurrentIndex( m_keyboardLayoutsModel->index( 0 ).row() );
347 }
348 }
349
350 QString
prettyStatus() const351 Config::prettyStatus() const
352 {
353 QString status;
354 status += tr( "Set keyboard model to %1.<br/>" )
355 .arg( m_keyboardModelsModel->label( m_keyboardModelsModel->currentIndex() ) );
356
357 QString layout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).second.description;
358 QString variant = m_keyboardVariantsModel->currentIndex() >= 0
359 ? m_keyboardVariantsModel->label( m_keyboardVariantsModel->currentIndex() )
360 : QString( "<default>" );
361 status += tr( "Set keyboard layout to %1/%2." ).arg( layout, variant );
362
363 return status;
364 }
365
366 Calamares::JobList
createJobs()367 Config::createJobs()
368 {
369 QList< Calamares::job_ptr > list;
370
371 Calamares::Job* j = new SetKeyboardLayoutJob( m_selectedModel,
372 m_selectedLayout,
373 m_selectedVariant,
374 m_additionalLayoutInfo,
375 m_xOrgConfFileName,
376 m_convertedKeymapPath,
377 m_writeEtcDefaultKeyboard );
378 list.append( Calamares::job_ptr( j ) );
379
380 return list;
381 }
382
383 static void
guessLayout(const QStringList & langParts,KeyboardLayoutModel * layouts,KeyboardVariantsModel * variants)384 guessLayout( const QStringList& langParts, KeyboardLayoutModel* layouts, KeyboardVariantsModel* variants )
385 {
386 bool foundCountryPart = false;
387 for ( auto countryPart = langParts.rbegin(); !foundCountryPart && countryPart != langParts.rend(); ++countryPart )
388 {
389 cDebug() << Logger::SubEntry << "looking for locale part" << *countryPart;
390 for ( int i = 0; i < layouts->rowCount(); ++i )
391 {
392 QModelIndex idx = layouts->index( i );
393 QString name
394 = idx.isValid() ? idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() : QString();
395 if ( idx.isValid() && ( name.compare( *countryPart, Qt::CaseInsensitive ) == 0 ) )
396 {
397 cDebug() << Logger::SubEntry << "matched" << name;
398 layouts->setCurrentIndex( i );
399 foundCountryPart = true;
400 break;
401 }
402 }
403 if ( foundCountryPart )
404 {
405 ++countryPart;
406 if ( countryPart != langParts.rend() )
407 {
408 cDebug() << "Next level:" << *countryPart;
409 for ( int variantnumber = 0; variantnumber < variants->rowCount(); ++variantnumber )
410 {
411 if ( variants->key( variantnumber ).compare( *countryPart, Qt::CaseInsensitive ) == 0 )
412 {
413 variants->setCurrentIndex( variantnumber );
414 cDebug() << Logger::SubEntry << "matched variant" << *countryPart << ' '
415 << variants->key( variantnumber );
416 }
417 }
418 }
419 }
420 }
421 }
422
423 void
guessLocaleKeyboardLayout()424 Config::guessLocaleKeyboardLayout()
425 {
426 if ( m_state != State::Initial )
427 {
428 return;
429 }
430 cScopedAssignment returnToIntial( &m_state, State::Initial );
431 m_state = State::Guessing;
432
433 /* Guessing a keyboard layout based on the locale means
434 * mapping between language identifiers in <lang>_<country>
435 * format to keyboard mappings, which are <country>_<layout>
436 * format; in addition, some countries have multiple languages,
437 * so fr_BE and nl_BE want different layouts (both Belgian)
438 * and sometimes the language-country name doesn't match the
439 * keyboard-country name at all (e.g. Ellas vs. Greek).
440 *
441 * This is a table of language-to-keyboard mappings. The
442 * language identifier is the key, while the value is
443 * a string that is used instead of the real language
444 * identifier in guessing -- so it should be something
445 * like <layout>_<country>.
446 */
447 static constexpr char arabic[] = "ara";
448 static const auto specialCaseMap = QMap< std::string, std::string >( {
449 /* Most Arab countries map to Arabic keyboard (Default) */
450 { "ar_AE", arabic },
451 { "ar_BH", arabic },
452 { "ar_DZ", arabic },
453 { "ar_EG", arabic },
454 { "ar_IN", arabic },
455 { "ar_IQ", arabic },
456 { "ar_JO", arabic },
457 { "ar_KW", arabic },
458 { "ar_LB", arabic },
459 { "ar_LY", arabic },
460 /* Not Morocco: use layout ma */
461 { "ar_OM", arabic },
462 { "ar_QA", arabic },
463 { "ar_SA", arabic },
464 { "ar_SD", arabic },
465 { "ar_SS", arabic },
466 /* Not Syria: use layout sy */
467 { "ar_TN", arabic },
468 { "ar_YE", arabic },
469 { "ca_ES", "cat_ES" }, /* Catalan */
470 { "en_CA", "us" }, /* Canadian English */
471 { "el_CY", "gr" }, /* Greek in Cyprus */
472 { "el_GR", "gr" }, /* Greek in Greece */
473 { "ig_NG", "igbo_NG" }, /* Igbo in Nigeria */
474 { "ha_NG", "hausa_NG" }, /* Hausa */
475 { "en_IN", "us" }, /* India, US English keyboards are common in India */
476 } );
477
478 // Try to preselect a layout, depending on language and locale
479 Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
480 QString lang = gs->value( "localeConf" ).toMap().value( "LANG" ).toString();
481
482 cDebug() << "Got locale language" << lang;
483 if ( !lang.isEmpty() )
484 {
485 // Chop off .codeset and @modifier
486 int index = lang.indexOf( '.' );
487 if ( index >= 0 )
488 {
489 lang.truncate( index );
490 }
491 index = lang.indexOf( '@' );
492 if ( index >= 0 )
493 {
494 lang.truncate( index );
495 }
496
497 lang.replace( '-', '_' ); // Normalize separators
498 }
499 if ( !lang.isEmpty() )
500 {
501 std::string lang_s = lang.toStdString();
502 if ( specialCaseMap.contains( lang_s ) )
503 {
504 QString newLang = QString::fromStdString( specialCaseMap.value( lang_s ) );
505 cDebug() << Logger::SubEntry << "special case language" << lang << "becomes" << newLang;
506 lang = newLang;
507 }
508 }
509 if ( !lang.isEmpty() )
510 {
511 guessLayout( lang.split( '_', SplitSkipEmptyParts ), m_keyboardLayoutsModel, m_keyboardVariantsModel );
512 }
513 }
514
515 void
finalize()516 Config::finalize()
517 {
518 Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
519 if ( !m_selectedLayout.isEmpty() )
520 {
521 gs->insert( "keyboardLayout", m_selectedLayout );
522 gs->insert( "keyboardVariant", m_selectedVariant ); //empty means default variant
523
524 if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() )
525 {
526 gs->insert( "keyboardAdditionalLayout", m_additionalLayoutInfo.additionalLayout );
527 gs->insert( "keyboardAdditionalLayout", m_additionalLayoutInfo.additionalVariant );
528 gs->insert( "keyboardVConsoleKeymap", m_additionalLayoutInfo.vconsoleKeymap );
529 }
530 }
531
532 //FIXME: also store keyboard model for something?
533 }
534
535 void
updateVariants(const QPersistentModelIndex & currentItem,QString currentVariant)536 Config::updateVariants( const QPersistentModelIndex& currentItem, QString currentVariant )
537 {
538 const auto variants = m_keyboardLayoutsModel->item( currentItem.row() ).second.variants;
539 m_keyboardVariantsModel->setVariants( variants );
540
541 auto index = -1;
542 for ( const auto& key : variants.keys() )
543 {
544 index++;
545 if ( variants[ key ] == currentVariant )
546 {
547 m_keyboardVariantsModel->setCurrentIndex( index );
548 return;
549 }
550 }
551 }
552
553 void
setConfigurationMap(const QVariantMap & configurationMap)554 Config::setConfigurationMap( const QVariantMap& configurationMap )
555 {
556 using namespace CalamaresUtils;
557
558 const auto xorgConfDefault = QStringLiteral( "00-keyboard.conf" );
559 m_xOrgConfFileName = getString( configurationMap, "xOrgConfFileName", xorgConfDefault );
560 if ( m_xOrgConfFileName.isEmpty() )
561 {
562 m_xOrgConfFileName = xorgConfDefault;
563 }
564 m_convertedKeymapPath = getString( configurationMap, "convertedKeymapPath" );
565 m_writeEtcDefaultKeyboard = getBool( configurationMap, "writeEtcDefaultKeyboard", true );
566 }
567
568 void
retranslate()569 Config::retranslate()
570 {
571 retranslateKeyboardModels();
572 }
573
574 void
selectionChange()575 Config::selectionChange()
576 {
577 if ( m_state == State::Initial )
578 {
579 m_state = State::UserSelected;
580 }
581 }
582