1 /*
2  * AppearancePreferencesPane.java
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 package org.rstudio.studio.client.workbench.prefs.views;
16 
17 import com.google.gwt.core.client.JsArrayString;
18 import com.google.gwt.core.client.Scheduler;
19 import com.google.gwt.core.client.Scheduler.RepeatingCommand;
20 import com.google.gwt.dom.client.SelectElement;
21 import com.google.gwt.dom.client.Style;
22 import com.google.gwt.dom.client.Style.Unit;
23 import com.google.gwt.event.dom.client.ChangeEvent;
24 import com.google.gwt.event.dom.client.ChangeHandler;
25 import com.google.gwt.resources.client.ImageResource;
26 import com.google.gwt.user.client.ui.FlowPanel;
27 import com.google.gwt.user.client.ui.HorizontalPanel;
28 import com.google.gwt.user.client.ui.VerticalPanel;
29 import com.google.inject.Inject;
30 
31 import org.rstudio.core.client.Debug;
32 import org.rstudio.core.client.StringUtil;
33 import org.rstudio.core.client.js.JsUtil;
34 import org.rstudio.core.client.prefs.RestartRequirement;
35 import org.rstudio.core.client.resources.ImageResource2x;
36 import org.rstudio.core.client.theme.ThemeFonts;
37 import org.rstudio.core.client.widget.FontDetector;
38 import org.rstudio.core.client.widget.Operation;
39 import org.rstudio.core.client.widget.SelectWidget;
40 import org.rstudio.core.client.widget.ThemedButton;
41 import org.rstudio.studio.client.RStudioGinjector;
42 import org.rstudio.studio.client.application.Desktop;
43 import org.rstudio.studio.client.application.DesktopInfo;
44 import org.rstudio.studio.client.common.FileDialogs;
45 import org.rstudio.studio.client.common.GlobalDisplay;
46 import org.rstudio.studio.client.common.dependencies.DependencyManager;
47 import org.rstudio.studio.client.server.ServerError;
48 import org.rstudio.studio.client.server.ServerRequestCallback;
49 import org.rstudio.studio.client.workbench.WorkbenchContext;
50 import org.rstudio.studio.client.workbench.prefs.model.UserPrefs;
51 import org.rstudio.studio.client.workbench.prefs.model.UserState;
52 import org.rstudio.studio.client.workbench.views.source.editors.text.themes.AceTheme;
53 import org.rstudio.studio.client.workbench.views.source.editors.text.themes.AceThemes;
54 import org.rstudio.studio.client.workbench.views.source.editors.text.themes.model.ThemeServerOperations;
55 
56 import java.util.HashMap;
57 import java.util.Set;
58 import java.util.TreeSet;
59 
60 public class AppearancePreferencesPane extends PreferencesPane
61 {
62 
63    @Inject
AppearancePreferencesPane(PreferencesDialogResources res, UserPrefs userPrefs, UserState userState, final AceThemes themes, WorkbenchContext workbenchContext, GlobalDisplay globalDisplay, DependencyManager dependencyManager, FileDialogs fileDialogs, ThemeServerOperations server)64    public AppearancePreferencesPane(PreferencesDialogResources res,
65                                     UserPrefs userPrefs,
66                                     UserState userState,
67                                     final AceThemes themes,
68                                     WorkbenchContext workbenchContext,
69                                     GlobalDisplay globalDisplay,
70                                     DependencyManager dependencyManager,
71                                     FileDialogs fileDialogs,
72                                     ThemeServerOperations server)
73    {
74       res_ = res;
75       userPrefs_ = userPrefs;
76       userState_ = userState;
77       globalDisplay_ = globalDisplay;
78       dependencyManager_ = dependencyManager;
79       server_ = server;
80 
81       VerticalPanel leftPanel = new VerticalPanel();
82 
83       relaunchRequired_ = false;
84 
85       // dark-grey theme used to be derived from default, now also applies to sky
86       if (StringUtil.equals(userPrefs_.globalTheme().getValue(), "dark-grey"))
87         userPrefs_.globalTheme().setGlobalValue(UserPrefs.GLOBAL_THEME_DEFAULT);
88 
89       final String originalTheme = userPrefs_.globalTheme().getValue();
90 
91       flatTheme_ = new SelectWidget("RStudio theme:",
92                                 new String[]{"Classic", "Modern", "Sky"},
93                                 new String[]{
94                                       UserPrefs.GLOBAL_THEME_CLASSIC,
95                                       UserPrefs.GLOBAL_THEME_DEFAULT,
96                                       UserPrefs.GLOBAL_THEME_ALTERNATE
97                                     },
98                                 false);
99       flatTheme_.addStyleName(res.styles().themeChooser());
100       flatTheme_.getListBox().setWidth("95%");
101       flatTheme_.getListBox().addChangeHandler(event ->
102          relaunchRequired_ = (StringUtil.equals(originalTheme, "classic") && !StringUtil.equals(flatTheme_.getValue(), "classic")) ||
103             (StringUtil.equals(flatTheme_.getValue(), "classic") && !StringUtil.equals(originalTheme, "classic")));
104 
105       String themeAlias = userPrefs_.globalTheme().getGlobalValue();
106       flatTheme_.setValue(themeAlias);
107 
108       leftPanel.add(flatTheme_);
109 
110       if (Desktop.hasDesktopFrame())
111       {
112          int initialIndex = -1;
113          int normalIndex = -1;
114          String[] zoomValues = new String[] {
115                "0.25", "0.50", "0.75", "0.80", "0.90",
116                "1.00", "1.10", "1.25", "1.50", "1.75",
117                "2.00", "2.50", "3.00", "4.00", "5.00"
118          };
119          String[] zoomLabels = new String[zoomValues.length];
120          double currentZoomLevel = DesktopInfo.getZoomLevel();
121          for (int i = 0; i < zoomValues.length; i++)
122          {
123             double zoomValue = Double.parseDouble(zoomValues[i]);
124 
125             if (zoomValue == 1.0)
126                normalIndex = i;
127 
128             if (zoomValue == currentZoomLevel)
129                initialIndex = i;
130 
131             zoomLabels[i] = StringUtil.formatPercent(zoomValue);
132          }
133 
134          if (initialIndex == -1)
135             initialIndex = normalIndex;
136 
137          zoomLevel_ = new SelectWidget("Zoom:",
138                                        zoomLabels,
139                                        zoomValues,
140                                        false);
141          zoomLevel_.getListBox().setSelectedIndex(initialIndex);
142          initialZoomLevel_ = zoomValues[initialIndex];
143 
144          leftPanel.add(zoomLevel_);
145 
146          zoomLevel_.getListBox().addChangeHandler(event -> updatePreviewZoomLevel());
147       }
148 
149       String[] fonts = new String[] {};
150 
151       if (Desktop.isDesktop())
152       {
153          // In desktop mode, get the list of installed fonts from Qt
154          String fontList = DesktopInfo.getFixedWidthFontList();
155 
156          if (fontList.isEmpty())
157             registerFontListReadyCallback();
158          else
159             fonts = fontList.split("\\n");
160       }
161       else
162       {
163          // In server mode, get the installed set of fonts by querying the server
164          getInstalledFontList();
165       }
166 
167       String fontFaceLabel = fonts.length == 0
168             ? "Editor font (loading...):"
169             : "Editor font:";
170 
171       fontFace_ = new SelectWidget(fontFaceLabel, fonts, fonts, false, false, false);
172       fontFace_.getListBox().setWidth("95%");
173 
174       if (Desktop.isDesktop())
175       {
176          // Get the fixed width font set in desktop mode
177          String value = DesktopInfo.getFixedWidthFont();
178          String label = value.replaceAll("\\\"", "");
179          if (!fontFace_.setValue(label))
180          {
181             fontFace_.insertValue(0, label, value);
182             fontFace_.setValue(value);
183          }
184       }
185       else
186       {
187          // In server mode, there's always a Default option which uses a
188          // browser-specific font.
189          fontFace_.insertValue(0, DEFAULT_FONT_NAME, DEFAULT_FONT_VALUE);
190       }
191 
192       initialFontFace_ = StringUtil.notNull(fontFace_.getValue());
193 
194       leftPanel.add(fontFace_);
195       fontFace_.addChangeHandler(new ChangeHandler()
196       {
197          @Override
198          public void onChange(ChangeEvent event)
199          {
200             String font = fontFace_.getValue();
201             if (font == null || StringUtil.equals(font, DEFAULT_FONT_VALUE))
202             {
203                preview_.setFont(ThemeFonts.getFixedWidthFont(), false);
204             }
205             else
206             {
207                preview_.setFont(font, !Desktop.hasDesktopFrame());
208             }
209          }
210       });
211 
212       String[] labels = {"7", "8", "9", "10", "11", "12", "13", "14", "16", "18", "24", "36"};
213       String[] values = new String[labels.length];
214       for (int i = 0; i < labels.length; i++)
215          values[i] = Double.parseDouble(labels[i]) + "";
216 
217       fontSize_ = new SelectWidget("Editor font size:",
218                                    labels,
219                                    values,
220                                    false);
221       fontSize_.getListBox().setWidth("95%");
222       if (!fontSize_.setValue(userPrefs.fontSizePoints().getGlobalValue() + ""))
223          fontSize_.getListBox().setSelectedIndex(3);
224       fontSize_.getListBox().addChangeHandler(new ChangeHandler()
225       {
226          public void onChange(ChangeEvent event)
227          {
228             preview_.setFontSize(Double.parseDouble(fontSize_.getValue()));
229          }
230       });
231 
232       theme_ = new SelectWidget("Editor theme:",
233                                 new String[0],
234                                 new String[0],
235                                 false);
236       theme_.getListBox().getElement().<SelectElement>cast().setSize(7);
237       theme_.getListBox().getElement().getStyle().setHeight(225, Unit.PX);
238       theme_.getListBox().addChangeHandler(new ChangeHandler()
239       {
240          @Override
241          public void onChange(ChangeEvent event)
242          {
243             AceTheme aceTheme = themeList_.get(theme_.getValue());
244             preview_.setTheme(aceTheme.getUrl());
245             removeThemeButton_.setEnabled(!aceTheme.isDefaultTheme());
246          }
247       });
248       theme_.addStyleName(res.styles().themeChooser());
249 
250       AceTheme currentTheme = userState_.theme().getGlobalValue().cast();
251       addThemeButton_ = new ThemedButton("Add...", event ->
252          fileDialogs.openFile(
253             "Theme Files (*.tmTheme *.rstheme)",
254             RStudioGinjector.INSTANCE.getRemoteFileSystemContext(),
255             workbenchContext.getCurrentWorkingDir(),
256             "Theme Files (*.tmTheme *.rstheme)",
257             (input, indicator) ->
258             {
259                if (input == null)
260                   return;
261 
262                String inputStem = input.getStem();
263                String inputPath = input.getPath();
264                boolean isTmTheme = StringUtil.equalsIgnoreCase(".tmTheme", input.getExtension());
265                boolean found = false;
266                for (AceTheme theme: themeList_.values())
267                {
268                   if (theme.isLocalCustomTheme() &&
269                      StringUtil.equalsIgnoreCase(theme.getFileStem(), inputStem))
270                   {
271                      showThemeExistsDialog(inputStem, () -> addTheme(inputPath, themes, isTmTheme));
272                      found = true;
273                      break;
274                   }
275                }
276 
277                if (!found)
278                {
279                   addTheme(inputPath, themes, isTmTheme);
280                }
281 
282                indicator.onCompleted();
283             }));
284       addThemeButton_.setLeftAligned(true);
285       removeThemeButton_ = new ThemedButton(
286          "Remove",
287          event -> showRemoveThemeWarning(
288             theme_.getValue(),
289             () -> removeTheme(theme_.getValue(), themes)));
290       removeThemeButton_.setLeftAligned(true);
291       removeThemeButton_.setEnabled(!currentTheme.isDefaultTheme());
292 
293       HorizontalPanel buttonPanel = new HorizontalPanel();
294       buttonPanel.add(addThemeButton_);
295       buttonPanel.add(removeThemeButton_);
296 
297       leftPanel.add(fontSize_);
298       leftPanel.add(theme_);
299       leftPanel.add(buttonPanel);
300 
301       FlowPanel previewPanel = new FlowPanel();
302 
303       previewPanel.setSize("100%", "100%");
304       preview_ = new AceEditorPreview(CODE_SAMPLE);
305       preview_.setHeight(previewDefaultHeight_);
306       preview_.setWidth("278px");
307       preview_.setFontSize(Double.parseDouble(fontSize_.getValue()));
308       preview_.setTheme(currentTheme.getUrl());
309       updatePreviewZoomLevel();
310       previewPanel.add(preview_);
311 
312       HorizontalPanel hpanel = new HorizontalPanel();
313       hpanel.setWidth("100%");
314       hpanel.add(leftPanel);
315       hpanel.setCellWidth(leftPanel, "160px");
316       hpanel.add(previewPanel);
317 
318       add(hpanel);
319 
320       // Themes are retrieved asynchronously, so we have to update the theme list and preview panel
321       // asynchronously too. We also need to wait until the next event cycle so that the progress
322       // indicator will be ready.
323       Scheduler.get().scheduleDeferred(() -> setThemes(themes));
324    }
325 
326    @Override
setPaneVisible(boolean visible)327    protected void setPaneVisible(boolean visible)
328    {
329       super.setPaneVisible(visible);
330       if (visible)
331       {
332          // When making the pane visible in desktop mode, add or remove a
333          // meaningless transform to the iframe hosting the preview. This is
334          // gross but necessary to work around a QtWebEngine bug which causes
335          // the region to not paint at all (literally showing the previous
336          // contents of the screen buffer) until invalidated in some way.
337          //
338          // Known to be an issue with Qt 5.12.8/Chromium 69; could be removed if
339          // the bug is fixed in later releases.
340          //
341          // See https://github.com/rstudio/rstudio/issues/6268
342 
343          Scheduler.get().scheduleDeferred(() ->
344          {
345             Style style = preview_.getElement().getStyle();
346             String translate = "translate(0px, 0px)";
347             String transform = style.getProperty("transform");
348             style.setProperty("transform",
349                     StringUtil.isNullOrEmpty(transform) || !StringUtil.equals(translate, transform) ?
350                         translate :
351                         "");
352          });
353       }
354    }
355 
removeTheme(String themeName, AceThemes themes, Operation afterOperation)356    private void removeTheme(String themeName, AceThemes themes, Operation afterOperation)
357    {
358       AceTheme currentTheme = userState_.theme().getGlobalValue().cast();
359       if (StringUtil.equalsIgnoreCase(currentTheme.getName(), themeName))
360       {
361          showCantRemoveActiveThemeDialog(currentTheme.getName());
362       }
363       else
364       {
365          themes.removeTheme(
366             themeName,
367             errorMessage -> showCantRemoveThemeDialog(themeName, errorMessage),
368             () ->
369             {
370                updateThemes(currentTheme.getName(), themes);
371                afterOperation.execute();
372             });
373       }
374    }
375 
removeTheme(String themeName, AceThemes themes)376    private void removeTheme(String themeName, AceThemes themes)
377    {
378       // No after operation necessary.
379       removeTheme(themeName, themes, () -> {});
380    }
381 
doAddTheme(String inputPath, AceThemes themes, boolean isTmTheme)382    private void doAddTheme(String inputPath, AceThemes themes, boolean isTmTheme)
383    {
384       if (isTmTheme)
385          dependencyManager_.withThemes(
386             "Converting a tmTheme to an rstheme",
387             () -> themes.addTheme(
388                inputPath,
389                result -> updateThemes(result, themes),
390                error -> showCantAddThemeDialog(inputPath, error)));
391       else
392          themes.addTheme(
393             inputPath,
394             result -> updateThemes(result, themes),
395             error -> showCantAddThemeDialog(inputPath, error));
396 
397    }
398 
addTheme(String inputPath, AceThemes themes, boolean isTmTheme)399    private void addTheme(String inputPath, AceThemes themes, boolean isTmTheme)
400    {
401       // Get the theme name and check if it's in the current list of themes.
402       themes.getThemeName(
403          inputPath,
404          name ->
405          {
406             if (themeList_.containsKey(name))
407             {
408                if (themeList_.get(name).isLocalCustomTheme())
409                {
410                   showDuplicateThemeError(
411                      name,
412                      () -> removeTheme(
413                         name,
414                         themes,
415                         () -> doAddTheme(inputPath, themes, isTmTheme)));
416                }
417                else
418                {
419                   showDuplicateThemeWarning(
420                      name,
421                      () -> doAddTheme(inputPath, themes, isTmTheme));
422                }
423             }
424             else
425             {
426                doAddTheme(inputPath, themes, isTmTheme);
427             }
428          },
429          error -> showCantAddThemeDialog(inputPath, error));
430    }
431 
setThemes(AceThemes themes)432    private void setThemes(AceThemes themes)
433    {
434       themes.getThemes(
435          themeList ->
436          {
437             themeList_ = themeList;
438 
439             // It's possible the current theme was removed outside the context of
440             // RStudio, so choose a default if it can't be found.
441             AceTheme currentTheme = userState_.theme().getGlobalValue().cast();
442             if (!themeList_.containsKey(currentTheme.getName()))
443             {
444                StringBuilder warningMsg = new StringBuilder();
445                warningMsg.append("The active theme \"")
446                   .append(currentTheme.getName())
447                   .append("\" could not be found. It's possible it was removed outside the context of RStudio. Switching to the ")
448                   .append(currentTheme.isDark() ? "dark " : "light ")
449                   .append("default theme: \"");
450 
451                currentTheme = AceTheme.createDefault(currentTheme.isDark());
452                userState_.theme().setGlobalValue(currentTheme);
453                preview_.setTheme(currentTheme.getUrl());
454 
455                warningMsg.append(currentTheme.getName())
456                   .append("\".");
457                Debug.logWarning(warningMsg.toString());
458             }
459 
460             theme_.setChoices(themeList_.keySet().toArray(new String[0]));
461             theme_.setValue(currentTheme.getName());
462             removeThemeButton_.setEnabled(!currentTheme.isDefaultTheme());
463          },
464          getProgressIndicator());
465    }
466 
updateThemes(String focusedThemeName, AceThemes themes)467    private void updateThemes(String focusedThemeName, AceThemes themes)
468    {
469       themes.getThemes(
470          themeList->
471          {
472             themeList_ = themeList;
473 
474             String themeName = focusedThemeName;
475             if (!themeList.containsKey(themeName))
476             {
477                Debug.logWarning("The theme \"" + focusedThemeName + "\" does not exist. It may have been manually deleted outside the context of RStudio.");
478                themeName = AceTheme.createDefault().getName();
479             }
480             AceTheme focusedTheme = themeList.get(themeName);
481 
482             theme_.setChoices(themeList_.keySet().toArray(new String[0]));
483             theme_.setValue(focusedTheme.getName());
484             preview_.setTheme(focusedTheme.getUrl());
485             removeThemeButton_.setEnabled(!focusedTheme.isDefaultTheme());
486          },
487          getProgressIndicator());
488    }
489 
updatePreviewZoomLevel()490    private void updatePreviewZoomLevel()
491    {
492       // no zoom preview on desktop
493       if (Desktop.hasDesktopFrame())
494       {
495          preview_.setZoomLevel(Double.parseDouble(zoomLevel_.getValue()) /
496                                DesktopInfo.getZoomLevel());
497       }
498    }
499 
showThemeExistsDialog(String inputFileName, Operation continueOperation)500    private void showThemeExistsDialog(String inputFileName, Operation continueOperation)
501    {
502       StringBuilder msg = new StringBuilder();
503       msg.append("A theme file with the same name, '")
504          .append(inputFileName)
505          .append("', already exists. Adding the theme will cause the existing file to be ")
506          .append("overwritten. Would you like to add the theme anyway?");
507       globalDisplay_.showYesNoMessage(
508          GlobalDisplay.MSG_WARNING,
509          "Theme File Already Exists",
510          msg.toString(),
511          continueOperation,
512          false);
513    }
514 
showCantAddThemeDialog(String themePath, String errorMessage)515    private void showCantAddThemeDialog(String themePath, String errorMessage)
516    {
517       StringBuilder msg = new StringBuilder();
518       msg.append("Unable to add the theme '")
519          .append(themePath)
520          .append("'. The following error occurred: ")
521          .append(errorMessage);
522 
523       globalDisplay_.showErrorMessage("Failed to Add Theme", msg.toString());
524    }
525 
showCantRemoveThemeDialog(String themeName, String errorMessage)526    private void showCantRemoveThemeDialog(String themeName, String errorMessage)
527    {
528       StringBuilder msg = new StringBuilder();
529       msg.append("Unable to remove the theme '")
530          .append(themeName)
531          .append("': ")
532          .append(errorMessage);
533 
534       globalDisplay_.showErrorMessage("Failed to Remove Theme", msg.toString());
535    }
536 
showCantRemoveActiveThemeDialog(String themeName)537    private void showCantRemoveActiveThemeDialog(String themeName)
538    {
539       StringBuilder msg = new StringBuilder();
540       msg.append("The theme \"")
541          .append(themeName)
542          .append("\" cannot be removed because it is currently in use. To delete this theme,")
543          .append(" please change the active theme and retry.");
544 
545       globalDisplay_.showErrorMessage("Cannot Remove Active Theme", msg.toString());
546    }
547 
showRemoveThemeWarning(String themeName, Operation continueOperation)548    private void showRemoveThemeWarning(String themeName, Operation continueOperation)
549    {
550       StringBuilder msg = new StringBuilder();
551       msg.append("Taking this action will delete the theme \"")
552          .append(themeName)
553          .append("\" and cannot be undone. Are you sure you wish to continue?");
554 
555       globalDisplay_.showYesNoMessage(
556          GlobalDisplay.MSG_WARNING,
557          "Remove Theme",
558          msg.toString(),
559          continueOperation,
560          false);
561    }
562 
showDuplicateThemeError(String themeName, Operation continueOperation)563    private void showDuplicateThemeError(String themeName, Operation continueOperation)
564    {
565       StringBuilder msg = new StringBuilder();
566       msg.append("There is an existing theme with the same name as the new theme in the current")
567          .append(" location. Would you like remove the existing theme, \"")
568          .append(themeName)
569          .append("\", and add the new theme?");
570 
571       globalDisplay_.showYesNoMessage(
572          GlobalDisplay.MSG_ERROR,
573          "Duplicate Theme In Same Location",
574          msg.toString(),
575          continueOperation,
576          false);
577    }
578 
showDuplicateThemeWarning(String themeName, Operation continueOperation)579    private void showDuplicateThemeWarning(String themeName, Operation continueOperation)
580    {
581       StringBuilder msg = new StringBuilder();
582       msg.append("There is an existing theme with the same name as the new theme, \"")
583          .append(themeName)
584          .append("\" in another location. The existing theme will be hidden but not removed.")
585          .append(" Removing the new theme later will un-hide the existing theme. Would you")
586          .append(" like to continue?");
587 
588       globalDisplay_.showYesNoMessage(
589          GlobalDisplay.MSG_WARNING,
590          "Duplicate Theme In Another Location",
591          msg.toString(),
592          continueOperation,
593          true);
594    }
595 
596    @Override
getIcon()597    public ImageResource getIcon()
598    {
599       return new ImageResource2x(res_.iconAppearance2x());
600    }
601 
602    @Override
initialize(UserPrefs prefs)603    protected void initialize(UserPrefs prefs)
604    {
605    }
606 
607    @Override
onApply(UserPrefs rPrefs)608    public RestartRequirement onApply(UserPrefs rPrefs)
609    {
610       RestartRequirement restartRequirement = super.onApply(rPrefs);
611 
612       if (relaunchRequired_)
613          restartRequirement.setUiReloadRequired(true);
614 
615       String themeName = flatTheme_.getValue();
616       if (!StringUtil.equals(themeName, userPrefs_.globalTheme().getGlobalValue()))
617       {
618          userPrefs_.globalTheme().setGlobalValue(themeName, false);
619       }
620 
621       double fontSize = Double.parseDouble(fontSize_.getValue());
622       userPrefs_.fontSizePoints().setGlobalValue(fontSize);
623       if (!StringUtil.equals(theme_.getValue(), userPrefs_.editorTheme().getGlobalValue()))
624       {
625          userState_.theme().setGlobalValue(themeList_.get(theme_.getValue()));
626          userPrefs_.editorTheme().setGlobalValue(theme_.getValue(), false);
627       }
628 
629      if (!StringUtil.equals(initialFontFace_, fontFace_.getValue()))
630      {
631         String fontFace = fontFace_.getValue();
632         initialFontFace_ = fontFace;
633         if (Desktop.hasDesktopFrame())
634         {
635            // In desktop mode the font is stored in a per-machine file since
636            // the font list varies between machines.
637            Desktop.getFrame().setFixedWidthFont(fontFace);
638         }
639         else
640         {
641            if (StringUtil.equals(fontFace, DEFAULT_FONT_VALUE))
642            {
643               // User has chosen the default font face
644               userPrefs_.serverEditorFontEnabled().setGlobalValue(false);
645            }
646            else
647            {
648               // User has chosen a specific font
649               userPrefs_.serverEditorFontEnabled().setGlobalValue(true);
650               userPrefs_.serverEditorFont().setGlobalValue(fontFace);
651            }
652         }
653         restartRequirement.setUiReloadRequired(true);
654      }
655 
656       if (Desktop.hasDesktopFrame())
657       {
658          if (!StringUtil.equals(initialZoomLevel_, zoomLevel_.getValue()))
659          {
660             double zoomLevel = Double.parseDouble(zoomLevel_.getValue());
661             initialZoomLevel_ = zoomLevel_.getValue();
662             Desktop.getFrame().setZoomLevel(zoomLevel);
663          }
664       }
665 
666       return restartRequirement;
667    }
668 
669    @Override
getName()670    public String getName()
671    {
672       return "Appearance";
673    }
674 
registerFontListReadyCallback()675    private final native void registerFontListReadyCallback()
676    /*-{
677 
678       var self = this;
679       $wnd.onFontListReady = $entry(function() {
680          self.@org.rstudio.studio.client.workbench.prefs.views.AppearancePreferencesPane::onFontListReady()();
681       });
682 
683    }-*/;
684 
onFontListReady()685    private void onFontListReady()
686    {
687       // NOTE: we use a short poll as we might receive this notification
688       // just before the Qt webchannel has been able to synchronize with
689       // the front-end
690       Scheduler.get().scheduleFixedDelay(new RepeatingCommand()
691       {
692          private int retryCount_ = 0;
693 
694          @Override
695          public boolean execute()
696          {
697             if (retryCount_++ > 20)
698                return false;
699 
700             String fonts = DesktopInfo.getFixedWidthFontList();
701             if (fonts.isEmpty())
702                return true;
703 
704             String[] fontList = fonts.split("\\n");
705             populateFontList(fontList);
706             return false;
707          }
708 
709       }, 100);
710    }
711 
getInstalledFontList()712    private void getInstalledFontList()
713    {
714       // Search for installed fixed-width fonts on this web browser.
715       final Set<String> browserFonts = new TreeSet<>();
716       JsArrayString candidates = userPrefs_.browserFixedWidthFonts().getGlobalValue();
717       for (String candidate: JsUtil.asIterable(candidates))
718       {
719          if (FontDetector.isFontSupported(candidate))
720          {
721             browserFonts.add(candidate);
722          }
723       }
724 
725       server_.getInstalledFonts(new ServerRequestCallback<JsArrayString>()
726       {
727          @Override
728          public void onResponseReceived(JsArrayString fonts)
729          {
730             browserFonts.addAll(JsUtil.toList(fonts));
731             populateFontList(browserFonts.toArray(new String[browserFonts.size()]));
732             fontFace_.insertValue(0, DEFAULT_FONT_NAME, DEFAULT_FONT_VALUE);
733 
734             String font = null;
735             if (userPrefs_.serverEditorFontEnabled().getValue())
736             {
737                // Use the user's supplied font
738                font = userPrefs_.serverEditorFont().getValue();
739             }
740 
741             if (StringUtil.isNullOrEmpty(font))
742             {
743                // No font selected
744                fontFace_.setValue(DEFAULT_FONT_VALUE);
745             }
746             else
747             {
748                // If there's a non-empty, enabled font, set it as the default
749                fontFace_.setValue(font);
750                preview_.setFont(font, true);
751             }
752 
753             initialFontFace_ = StringUtil.notNull(fontFace_.getValue());
754          }
755 
756          @Override
757          public void onError(ServerError error)
758          {
759             // Change label so it doesn't load indefinitely
760             fontFace_.setLabel("Editor font:");
761 
762             Debug.logError(error);
763          }
764       });
765    }
766 
populateFontList(String[] fontList)767    private void populateFontList(String[] fontList)
768    {
769       String value = fontFace_.getValue();
770       if (!StringUtil.isNullOrEmpty(value))
771          value = value.replaceAll("\\\"", "");
772       fontFace_.setLabel("Editor font:");
773       fontFace_.setChoices(fontList, fontList);
774       fontFace_.setValue(value);
775    }
776 
777    private final PreferencesDialogResources res_;
778    private final UserPrefs userPrefs_;
779    private final UserState userState_;
780    private SelectWidget helpFontSize_;
781    private SelectWidget fontSize_;
782    private SelectWidget theme_;
783    private ThemedButton addThemeButton_;
784    private ThemedButton removeThemeButton_;
785    private final AceEditorPreview preview_;
786    private SelectWidget fontFace_;
787    private String initialFontFace_;
788    private SelectWidget zoomLevel_;
789    private String initialZoomLevel_;
790    private final SelectWidget flatTheme_;
791    private Boolean relaunchRequired_;
792    private static String previewDefaultHeight_ = "533px";
793    private HashMap<String, AceTheme> themeList_;
794    private final GlobalDisplay globalDisplay_;
795    private final DependencyManager dependencyManager_;
796    private final ThemeServerOperations server_;
797    private int renderPass_ = 1;
798 
799    private final static String DEFAULT_FONT_NAME = "(Default)";
800    private final static String DEFAULT_FONT_VALUE = "__default__";
801 
802    private static final String CODE_SAMPLE =
803          "# plotting of R objects\n" +
804          "plot <- function (x, y, ...)\n" +
805          "{\n" +
806          "  if (is.function(x) && \n" +
807          "      is.null(attr(x, \"class\")))\n" +
808          "  {\n" +
809          "    if (missing(y))\n" +
810          "      y <- NULL\n" +
811          "    \n" +
812          "    # check for ylab argument\n" +
813          "    hasylab <- function(...) \n" +
814          "      !all(is.na(\n" +
815          "        pmatch(names(list(...)),\n" +
816          "              \"ylab\")))\n" +
817          "    \n" +
818          "    if (hasylab(...))\n" +
819          "      plot.function(x, y, ...)\n" +
820          "    \n" +
821          "    else \n" +
822          "      plot.function(\n" +
823          "        x, y, \n" +
824          "        ylab = paste(\n" +
825          "          deparse(substitute(x)),\n" +
826          "          \"(x)\"), \n" +
827          "        ...)\n" +
828          "  }\n" +
829          "  else \n" +
830          "    UseMethod(\"plot\")\n" +
831          "}\n";
832 }
833