1 /*
2  * RmdTemplateOptionsWidget.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.rmarkdown.ui;
16 
17 import java.util.ArrayList;
18 import java.util.HashMap;
19 import java.util.List;
20 import java.util.Map;
21 
22 import com.google.gwt.aria.client.Id;
23 import com.google.gwt.aria.client.Roles;
24 import com.google.gwt.dom.client.Style;
25 import com.google.gwt.user.client.DOM;
26 import org.rstudio.core.client.JsArrayUtil;
27 import org.rstudio.core.client.files.FileSystemItem;
28 import org.rstudio.core.client.theme.res.ThemeResources;
29 import org.rstudio.core.client.theme.res.ThemeStyles;
30 import org.rstudio.studio.client.common.FilePathUtils;
31 import org.rstudio.studio.client.rmarkdown.model.RmdFrontMatter;
32 import org.rstudio.studio.client.rmarkdown.model.RmdFrontMatterOutputOptions;
33 import org.rstudio.studio.client.rmarkdown.model.RmdTemplate;
34 import org.rstudio.studio.client.rmarkdown.model.RmdTemplateFormat;
35 import org.rstudio.studio.client.rmarkdown.model.RmdTemplateFormatOption;
36 
37 import com.google.gwt.core.client.GWT;
38 import com.google.gwt.core.client.JavaScriptObject;
39 import com.google.gwt.core.client.JsArray;
40 import com.google.gwt.core.client.JsArrayString;
41 import com.google.gwt.dom.client.Element;
42 import com.google.gwt.dom.client.Style.Overflow;
43 import com.google.gwt.event.dom.client.ChangeEvent;
44 import com.google.gwt.event.dom.client.ChangeHandler;
45 import com.google.gwt.resources.client.CssResource;
46 import com.google.gwt.uibinder.client.UiBinder;
47 import com.google.gwt.uibinder.client.UiField;
48 import com.google.gwt.user.client.ui.Composite;
49 import com.google.gwt.user.client.ui.FlowPanel;
50 import com.google.gwt.user.client.ui.Label;
51 import com.google.gwt.user.client.ui.ListBox;
52 import com.google.gwt.user.client.ui.ScrollPanel;
53 import com.google.gwt.user.client.ui.TabLayoutPanel;
54 import com.google.gwt.user.client.ui.Widget;
55 
56 public class RmdTemplateOptionsWidget extends Composite
57 {
58 
59    private static RmdTemplateOptionsWidgetUiBinder uiBinder = GWT
60          .create(RmdTemplateOptionsWidgetUiBinder.class);
61 
62    interface RmdTemplateOptionsWidgetUiBinder extends
63          UiBinder<Widget, RmdTemplateOptionsWidget>
64    {
65    }
66 
67    interface OptionsStyle extends CssResource
68    {
optionWidget()69       String optionWidget();
70    }
71 
RmdTemplateOptionsWidget(boolean allowFormatChange)72    public RmdTemplateOptionsWidget(boolean allowFormatChange)
73    {
74       optionsTabs_ = new TabLayoutPanel(30, Style.Unit.PX, "R Markdown Options");
75       initWidget(uiBinder.createAndBindUi(this));
76       style.ensureInjected();
77       allowFormatChange_ = allowFormatChange;
78       if (allowFormatChange)
79       {
80          listFormats_.addChangeHandler(new ChangeHandler()
81          {
82             @Override
83             public void onChange(ChangeEvent event)
84             {
85                updateFormatOptions(getSelectedFormat());
86             }
87          });
88       }
89       else
90       {
91          listFormats_.setVisible(false);
92          listFormats_.setEnabled(false);
93          labelFormatNotes_.setVisible(false);
94          labelFormatName_.setVisible(true);
95       }
96 
97       ThemeStyles styles = ThemeResources.INSTANCE.themeStyles();
98       optionsTabs_.addStyleName(styles.dialogTabPanel());
99    }
100 
setTemplate(RmdTemplate template, boolean forCreate)101    public void setTemplate(RmdTemplate template, boolean forCreate)
102    {
103       setTemplate(template, forCreate, null);
104    }
105 
setTemplate(RmdTemplate template, boolean forCreate, RmdFrontMatter frontMatter)106    public void setTemplate(RmdTemplate template, boolean forCreate,
107                            RmdFrontMatter frontMatter)
108    {
109       template_ = template;
110       formats_ = template.getFormats();
111       options_ = template.getOptions();
112       if (frontMatter != null)
113          applyFrontMatter(frontMatter);
114       listFormats_.clear();
115       for (int i = 0; i < formats_.length(); i++)
116       {
117          listFormats_.addItem(formats_.get(i).getUiName(),
118                               formats_.get(i).getName());
119       }
120       updateFormatOptions(getSelectedFormat());
121    }
122 
setDocument(FileSystemItem document)123    public void setDocument(FileSystemItem document)
124    {
125       document_ = document;
126    }
127 
getSelectedFormat()128    public String getSelectedFormat()
129    {
130       return listFormats_.getValue(listFormats_.getSelectedIndex());
131    }
132 
getOutputOptions()133    public RmdFrontMatterOutputOptions getOutputOptions()
134    {
135       return RmdFormatOptionsHelper.optionsListToJson(
136             optionWidgets_,
137             document_,
138             frontMatter_.getOutputOption(getSelectedFormat())).cast();
139    }
140 
setSelectedFormat(String format)141    public void setSelectedFormat(String format)
142    {
143       if (allowFormatChange_)
144       {
145          for (int i = 0; i < listFormats_.getItemCount(); i++)
146          {
147             if (listFormats_.getValue(i) == format)
148             {
149                listFormats_.setSelectedIndex(i);
150                updateFormatOptions(format);
151             }
152          }
153       }
154       else
155       {
156          RmdTemplateFormat selFormat = template_.getFormat(format);
157          if (selFormat != null)
158             labelFormatName_.setText("Shiny " + selFormat.getUiName());
159       }
160    }
161 
getOptionsJSON()162    public JavaScriptObject getOptionsJSON()
163    {
164       return RmdFormatOptionsHelper.optionsListToJson(
165             optionWidgets_,
166             document_,
167             frontMatter_ == null ?
168                   null : frontMatter_.getOutputOption(getSelectedFormat()));
169    }
170 
updateFormatOptions(String format)171    private void updateFormatOptions(String format)
172    {
173       tabs_ = new HashMap<>();
174       optionsTabs_.clear();
175       for (int i = 0; i < formats_.length(); i++)
176       {
177          if (formats_.get(i).getName() == format)
178          {
179             addFormatOptions(formats_.get(i));
180             break;
181          }
182       }
183    }
184 
addFormatOptions(RmdTemplateFormat format)185    private void addFormatOptions(RmdTemplateFormat format)
186    {
187       if (format.getNotes().length() > 0 && allowFormatChange_)
188       {
189          labelFormatNotes_.setText(format.getNotes());
190          labelFormatNotes_.setVisible(true);
191       }
192       else
193       {
194          labelFormatNotes_.setVisible(false);
195       }
196       optionWidgets_ = new ArrayList<>();
197       JsArrayString options = format.getOptions();
198       for (int i = 0; i < options.length(); i++)
199       {
200          RmdFormatOption optionWidget;
201          RmdTemplateFormatOption option = findOption(format.getName(),
202                                                      options.get(i));
203          if (option == null)
204             continue;
205 
206          String initialValue = option.getDefaultValue();
207 
208          // check to see whether a value for this format and option were
209          // specified in the front matter
210          String frontMatterValue = getFrontMatterDefault(
211                format.getName(), option.getName());
212          if (frontMatterValue != null)
213             initialValue = frontMatterValue;
214 
215          optionWidget = createWidgetForOption(option, initialValue);
216          if (optionWidget == null)
217             continue;
218 
219          optionWidget.asWidget().addStyleName(style.optionWidget());
220 
221          FlowPanel panel = null;
222          String category = option.getCategory();
223          if (tabs_.containsKey(category))
224          {
225             panel = tabs_.get(category);
226          }
227          else
228          {
229             ScrollPanel scrollPanel = new ScrollPanel();
230             panel = new FlowPanel();
231             scrollPanel.add(panel);
232             optionsTabs_.add(scrollPanel, new Label(category));
233 
234             // associate tabpanel widget with controlling tab
235             Roles.getTabpanelRole().set(scrollPanel.getElement());
236             String tabId = DOM.createUniqueId();
237             optionsTabs_.setTabId(scrollPanel, tabId);
238             Roles.getTabpanelRole().setAriaLabelledbyProperty(scrollPanel.getElement(), Id.of(tabId));
239 
240             tabs_.put(category, panel);
241          }
242 
243          panel.add(optionWidget);
244          optionWidgets_.add(optionWidget);
245       }
246 
247       // we need to center the tabs and overlay them on the top edge of the
248       // content; to do this, it is necessary to nuke a couple of the inline
249       // styles used by the default GWT tab panel.
250       Element tabOuter = (Element) optionsTabs_.getElement().getChild(1);
251       tabOuter.getStyle().setOverflow(Overflow.VISIBLE);
252       Element tabInner = (Element) tabOuter.getFirstChild();
253       tabInner.getStyle().clearWidth();
254    }
255 
createWidgetForOption(RmdTemplateFormatOption option, String initialValue)256    private RmdFormatOption createWidgetForOption(RmdTemplateFormatOption option,
257                                                  String initialValue)
258    {
259       RmdFormatOption optionWidget = null;
260       if (option.getType() == RmdTemplateFormatOption.TYPE_BOOLEAN)
261       {
262          optionWidget = new RmdBooleanOption(option, initialValue);
263       }
264       else if (option.getType() == RmdTemplateFormatOption.TYPE_CHOICE)
265       {
266          optionWidget = new RmdChoiceOption(option, initialValue);
267       }
268       else if (option.getType() == RmdTemplateFormatOption.TYPE_STRING)
269       {
270          optionWidget = new RmdStringOption(option, initialValue);
271       }
272       else if (option.getType() == RmdTemplateFormatOption.TYPE_FLOAT ||
273                option.getType() == RmdTemplateFormatOption.TYPE_INTEGER)
274       {
275          optionWidget = new RmdFloatOption(option, initialValue);
276       }
277       else if (option.getType() == RmdTemplateFormatOption.TYPE_FILE)
278       {
279          // if we have a document and a relative path, resolve the path
280          // relative to the document
281          if (document_ != null && initialValue != "null" &&
282              FilePathUtils.pathIsRelative(initialValue))
283          {
284             initialValue =
285                   document_.getParentPath().completePath(initialValue);
286          }
287          optionWidget = new RmdFileOption(option, initialValue);
288       }
289       return optionWidget;
290    }
291 
findOption(String formatName, String optionName)292    private RmdTemplateFormatOption findOption(String formatName,
293                                               String optionName)
294    {
295       RmdTemplateFormatOption result = null;
296       for (int i = 0; i < options_.length(); i++)
297       {
298          RmdTemplateFormatOption option = options_.get(i);
299 
300          // Not the option we're looking for
301          if (option.getName() != optionName)
302             continue;
303 
304          String optionFormatName = option.getFormatName();
305          if (optionFormatName.length() > 0)
306          {
307             // A format-specific option: if it's for this format we're done,
308             // otherwise keep looking
309             if (optionFormatName.equals(formatName))
310                return option;
311             else
312                continue;
313          }
314 
315          result = option;
316       }
317       return result;
318    }
319 
applyFrontMatter(RmdFrontMatter frontMatter)320    private void applyFrontMatter(RmdFrontMatter frontMatter)
321    {
322       frontMatter_ = frontMatter;
323       frontMatterCache_ = new HashMap<>();
324       ensureOptionsCache();
325       JsArrayString formats = frontMatter.getFormatList();
326       for (int i = 0; i < formats.length(); i++)
327       {
328          String format = formats.get(i);
329          RmdFrontMatterOutputOptions options =
330                frontMatter.getOutputOption(format);
331          JsArrayString optionList = options.getOptionList();
332          for (int j = 0; j < optionList.length(); j++)
333          {
334             String option = optionList.get(j);
335             String value = options.getOptionValue(option);
336             frontMatterCache_.put(format + ":" + option, value);
337             if (optionCache_.containsKey(option))
338             {
339                // If the option is specifically labeled as transferable
340                // between formats, add a generic key to be applied to other
341                // formats
342                RmdTemplateFormatOption formatOption = optionCache_.get(option);
343                if (formatOption.isTransferable())
344                {
345                   frontMatterCache_.put(option, value);
346                }
347             }
348          }
349       }
350    }
351 
getFrontMatterDefault(String formatName, String optionName)352    private String getFrontMatterDefault(String formatName, String optionName)
353    {
354       // if we have no front matter, we have no default
355       if (frontMatterCache_ == null)
356          return null;
357 
358       // is this value defined in the front matter?
359       String key = formatName + ":" + optionName;
360       if (frontMatterCache_.containsKey(key))
361          return frontMatterCache_.get(key);
362       else
363       {
364          // is this value transferable from a format defined in the front
365          // matter? (don't transfer options into formats explicitly defined
366          // in the front matter)
367          JsArrayString frontMatterFormats = frontMatter_.getFormatList();
368          if ((!JsArrayUtil.jsArrayStringContains(frontMatterFormats, formatName))
369                &&
370              frontMatterCache_.containsKey(optionName))
371          {
372             return frontMatterCache_.get(optionName);
373          }
374       }
375       return null;
376    }
377 
ensureOptionsCache()378    private void ensureOptionsCache()
379    {
380       if (optionCache_ != null)
381          return;
382       optionCache_ = new HashMap<>();
383       for (int i = 0; i < options_.length(); i++)
384       {
385          RmdTemplateFormatOption option = options_.get(i);
386          if (option.getFormatName().length() > 0)
387             continue;
388          optionCache_.put(option.getName(), option);
389       }
390    }
391 
392    private RmdTemplate template_;
393    private JsArray<RmdTemplateFormat> formats_;
394    private JsArray<RmdTemplateFormatOption> options_;
395    private List<RmdFormatOption> optionWidgets_;
396    private RmdFrontMatter frontMatter_;
397    private FileSystemItem document_;
398    private boolean allowFormatChange_;
399 
400    // Cache of options present in the template (ignores those options that
401    // are specifically marked for a format)
402    private Map<String, RmdTemplateFormatOption> optionCache_;
403 
404    // Cache of values set in the front matter, e.g.:
405    // "html_document:fig_width" => "7.5"
406    // In the case of options that are marked as transferable, maps directly
407    // from an option name to its default, e.g.
408    // "toc" => "true"
409    private Map<String, String> frontMatterCache_;
410 
411    private Map<String, FlowPanel> tabs_;
412 
413    @UiField ListBox listFormats_;
414    @UiField Label labelFormatNotes_;
415    @UiField Label labelFormatName_;
416    @UiField(provided=true) TabLayoutPanel optionsTabs_;
417    @UiField OptionsStyle style;
418 }
419