001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028
029package edu.wisc.ssec.mcidasv.startupmanager.options;
030
031import java.awt.*;
032import java.awt.geom.AffineTransform;
033import java.io.BufferedReader;
034import java.io.BufferedWriter;
035import java.io.File;
036import java.io.FileNotFoundException;
037import java.io.FileReader;
038import java.io.FileWriter;
039import java.io.IOException;
040import java.util.ArrayList;
041import java.util.Collection;
042import java.util.Collections;
043import java.util.HashMap;
044import java.util.List;
045import java.util.Map;
046import java.util.stream.Collectors;
047
048import edu.wisc.ssec.mcidasv.startupmanager.StartupManager;
049import edu.wisc.ssec.mcidasv.startupmanager.Platform;
050import org.slf4j.Logger;
051import org.slf4j.LoggerFactory;
052
053public class OptionMaster {
054    
055    private static final Logger logger =
056        LoggerFactory.getLogger(OptionMaster.class);
057    
058    public final static String SET_PREFIX = "SET ";
059    public final static String EMPTY_STRING = "";
060    public final static String QUOTE_STRING = "\"";
061    public final static char QUOTE_CHAR = '"';
062    public final static String DEF_SCALING = "1";
063
064    // TODO(jon): write CollectionHelpers.zip() and CollectionHelpers.zipWith()
065    public final Object[][] blahblah = {
066        // Default memory initial setting is 80% of system memory
067        { "HEAP_SIZE", "Memory", "80P", Type.MEMORY, OptionPlatform.ALL, Visibility.VISIBLE },
068        { "JOGL_TOGL", "Enable JOGL", "1", Type.BOOLEAN, OptionPlatform.UNIXLIKE, Visibility.VISIBLE },
069        { "USE_3DSTUFF", "Enable 3D controls", "1", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
070        { "DEFAULT_LAYOUT", "Load default layout", "1", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
071        { "STARTUP_BUNDLE", "Defaults", "0;", Type.FILE, OptionPlatform.ALL, Visibility.VISIBLE },
072        // mcidasv enables this (the actual property is "visad.java3d.geometryByRef")
073        // by default in mcidasv.properties.
074        { "USE_GEOBYREF", "Enable access to geometry by reference", "1", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
075        { "USE_IMAGEBYREF", "Enable access to image data by reference", "1", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
076        { "USE_NPOT", "Enable Non-Power of Two (NPOT) textures", "0", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
077        // USE_CMSGC is no longer in use, so the visibility is "HIDDEN".
078        // If we remove the option entirely, existing users with USE_CMSGC will may see
079        // a warning message.
080        { "USE_CMSGC", "Enable concurrent mark-sweep garbage collector", "0", Type.BOOLEAN, OptionPlatform.ALL, Visibility.HIDDEN },
081        { "LOG_LEVEL", "Log Level", "INFO", Type.LOGLEVEL, OptionPlatform.ALL, Visibility.VISIBLE },
082        { "JVM_OPTIONS", "Java Virtual Machine Options", "", Type.TEXT, OptionPlatform.ALL, Visibility.VISIBLE },
083        { "TEXTURE_WIDTH", "Texture Size", "4096", Type.TEXT, OptionPlatform.ALL, Visibility.VISIBLE },
084        { "MCV_SCALING", "GUI Scaling", DEF_SCALING, Type.TEXT, OptionPlatform.ALL, Visibility.VISIBLE },
085        { "USE_DARK_MODE", "Enable Dark Mode", "0", Type.BOOLEAN, OptionPlatform.ALL, Visibility.VISIBLE },
086    };
087    
088    /**
089     * {@link Option}s can be either platform-specific or applicable to all
090     * platforms. Options that are platform-specific still appear in the 
091     * UI, but their component is not enabled.
092     */
093    public enum OptionPlatform { ALL, UNIXLIKE, WINDOWS, MAC };
094    
095    /**
096     * The different types of {@link Option}s.
097     * 
098     * @see TextOption
099     * @see BooleanOption
100     * @see MemoryOption
101     * @see DirectoryOption
102     * @see SliderOption
103     * @see LoggerLevelOption
104     * @see FileOption
105     */
106    public enum Type { TEXT, BOOLEAN, MEMORY, DIRTREE, SLIDER, LOGLEVEL, FILE };
107    
108    /** 
109     * Different ways that an {@link Option} might be displayed.
110     */
111    public enum Visibility { VISIBLE, HIDDEN };
112    
113    /** Maps an option ID to the corresponding object. */
114    private Map<String, ? extends Option> optionMap;
115    
116    private static OptionMaster instance;
117    
118    public OptionMaster() {
119        normalizeUserDirectory();
120        optionMap = buildOptions(blahblah);
121//        readStartup();
122    }
123    
124    public static OptionMaster getInstance() {
125        if (instance == null) {
126            instance = new OptionMaster();
127        }
128        return instance;
129    }
130
131    // McIDAS Inquiry #3124-3141 and #3125-3141 -> Unused but it might be useful some time in the future
132    // HiDPI, Retina Display, MacOS
133    public static boolean hasRetinaDisplay() {
134        logger.info("Testing for HiDPI");
135        final GraphicsConfiguration config = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration();
136        final AffineTransform transform = config.getDefaultTransform();
137        return !transform.isIdentity();
138    }
139
140    /**
141     * Creates the specified options and returns a mapping of the option ID
142     * to the actual {@link Option} object.
143     * 
144     * @param options An array specifying the {@code Option}s to be built.
145     * 
146     * @return Mapping of ID to {@code Option}.
147     * 
148     * @throws AssertionError if the option array contained an entry that
149     * this method cannot build.
150     */
151    private Map<String, Option> buildOptions(final Object[][] options) {
152        // TODO(jon): seriously, get that zip stuff working! this array 
153        // stuff is BAD.
154        Map<String, Option> optMap = new HashMap<>(options.length);
155        
156        for (Object[] arrayOption : options) {
157            String id = (String)arrayOption[0];
158            String label = (String)arrayOption[1];
159            String defaultValue = (String)arrayOption[2];
160            Type type = (Type)arrayOption[3];
161            OptionPlatform platform = (OptionPlatform)arrayOption[4];
162            Visibility visibility = (Visibility)arrayOption[5];
163            
164            switch (type) {
165                case TEXT:
166                    optMap.put(id, new TextOption(id, label, defaultValue, platform, visibility));
167                    break;
168                case BOOLEAN:
169                    optMap.put(id, new BooleanOption(id, label, defaultValue, platform, visibility));
170                    break;
171                case MEMORY:
172                    optMap.put(id, new MemoryOption(id, label, defaultValue, platform, visibility));
173                    break;
174                case DIRTREE:
175                    optMap.put(id, new DirectoryOption(id, label, defaultValue, platform, visibility));
176                    break;
177                case SLIDER:
178                    optMap.put(id, new SliderOption(id, label, defaultValue, platform, visibility));
179                    break;
180                case LOGLEVEL:
181                    optMap.put(id, new LoggerLevelOption(id, label, defaultValue, platform, visibility));
182                    break;
183                case FILE:
184                    optMap.put(id, new FileOption(id, label, defaultValue, platform, visibility));
185                    break;
186                default:
187                     throw new AssertionError(type + 
188                         " is not known to OptionMaster.buildOptions()");
189            }
190        }
191        return optMap;
192    }
193    
194    /**
195     * Converts a {@link Platform} to its corresponding 
196     * {@link OptionPlatform} type.
197     * 
198     * @return The current platform as a {@code OptionPlatform} type.
199     * 
200     * @throws AssertionError if {@link StartupManager#getPlatform()} 
201     * returned something that this method cannot convert.
202     */
203    // a lame-o hack :(
204    protected OptionPlatform convertToOptionPlatform() {
205        Platform platform = StartupManager.getInstance().getPlatform();
206        switch (platform) {
207            case WINDOWS: 
208                return OptionPlatform.WINDOWS;
209            case MAC:
210                return OptionPlatform.MAC;
211            case UNIXLIKE: 
212                return OptionPlatform.UNIXLIKE;
213            default: 
214                throw new AssertionError("Unknown platform: " + platform);
215        }
216    }
217    
218    /**
219     * Returns the {@link Option} mapped to {@code id}.
220     * 
221     * @param id The ID whose associated {@code Option} is to be returned.
222     * 
223     * @return Either the {@code Option} associated with {@code id}, or 
224     * {@code null} if there was no association.
225     * 
226     * 
227     * @see #getMemoryOption
228     * @see #getBooleanOption
229     * @see #getDirectoryOption
230     * @see #getSliderOption
231     * @see #getTextOption
232     * @see #getLoggerLevelOption
233     * @see #getFileOption
234     */
235    private Option getOption(final String id) {
236        return optionMap.get(id);
237    }
238    
239    /**
240     * Searches {@link #optionMap} for the {@link MemoryOption} that 
241     * corresponds with the given {@code id}.
242     * 
243     * @param id Identifier for the desired {@code MemoryOption}. 
244     * Should not be {@code null}.
245     * 
246     * @return Either the {@code MemoryOption} that corresponds to {@code id} 
247     * or {@code null}.
248     */
249    public MemoryOption getMemoryOption(final String id) {
250        return (MemoryOption)optionMap.get(id);
251    }
252    
253    /**
254     * Searches {@link #optionMap} for the {@link BooleanOption} that 
255     * corresponds with the given {@code id}.
256     * 
257     * @param id Identifier for the desired {@code BooleanOption}. 
258     * Should not be {@code null}.
259     * 
260     * @return Either the {@code BooleanOption} that corresponds to {@code id} 
261     * or {@code null}.
262     */
263    public BooleanOption getBooleanOption(final String id) {
264        return (BooleanOption)optionMap.get(id);
265    }
266    
267    /**
268     * Searches {@link #optionMap} for the {@link DirectoryOption} that 
269     * corresponds with the given {@code id}.
270     * 
271     * @param id Identifier for the desired {@code DirectoryOption}. 
272     * Should not be {@code null}.
273     * 
274     * @return Either the {@code DirectoryOption} that corresponds to 
275     * {@code id} or {@code null}.
276     */
277    public DirectoryOption getDirectoryOption(final String id) {
278        return (DirectoryOption)optionMap.get(id);
279    }
280    
281    /**
282     * Searches {@link #optionMap} for the {@link SliderOption} that 
283     * corresponds with the given {@code id}.
284     * 
285     * @param id Identifier for the desired {@code SliderOption}. 
286     * Should not be {@code null}.
287     * 
288     * @return Either the {@code SliderOption} that corresponds to {@code id} 
289     * or {@code null}.
290     */
291    public SliderOption getSliderOption(final String id) {
292        return (SliderOption)optionMap.get(id);
293    }
294    
295    /**
296     * Searches {@link #optionMap} for the {@link TextOption} that 
297     * corresponds with the given {@code id}.
298     * 
299     * @param id Identifier for the desired {@code TextOption}. 
300     * Should not be {@code null}.
301     * 
302     * @return Either the {@code TextOption} that corresponds to {@code id} 
303     * or {@code null}.
304     */
305    public TextOption getTextOption(final String id) {
306        return (TextOption)optionMap.get(id);
307    }
308    
309    /**
310     * Searches {@link #optionMap} for the {@link LoggerLevelOption} that 
311     * corresponds with the given {@code id}.
312     * 
313     * @param id Identifier for the desired {@code LoggerLevelOption}. 
314     * Should not be {@code null}.
315     * 
316     * @return Either the {@code LoggerLevelOption} that corresponds to {@code id} 
317     * or {@code null}.
318     */
319    public LoggerLevelOption getLoggerLevelOption(final String id) {
320        return (LoggerLevelOption)optionMap.get(id);
321    }
322
323    public FileOption getFileOption(final String id) {
324        return (FileOption)optionMap.get(id);
325    }
326
327    // TODO(jon): getAllOptions and optionsBy* really need some work.
328    // I want to eventually do something like:
329    // Collection<Option> = getOpts().byPlatform(WINDOWS, ALL).byType(BOOLEAN).byVis(HIDDEN)
330    /**
331     * Returns all the available startup manager options.
332     * 
333     * @return Either all available startup manager options or an empty 
334     * {@link Collection}.
335     */
336    public Collection<Option> getAllOptions() {
337        return Collections.unmodifiableCollection(optionMap.values());
338    }
339    
340    /**
341     * Returns the {@link Option Options} applicable to the given 
342     * {@link OptionPlatform OptionPlatforms}.
343     * 
344     * @param platforms Desired platforms. Cannot be {@code null}.
345     * 
346     * @return Either a {@link List} of {code Option}-s applicable to
347     * {@code platforms} or an empty {@code List}.
348     */
349    public List<Option> optionsByPlatform(
350        final Collection<OptionPlatform> platforms) 
351    {
352        if (platforms == null) {
353            throw new NullPointerException("must specify platforms");
354        }
355        Collection<Option> allOptions = getAllOptions();
356        List<Option> filteredOptions = 
357            new ArrayList<>(allOptions.size());
358
359        filteredOptions.addAll(
360            allOptions.stream()
361                      .filter(option ->
362                              platforms.contains(option.getOptionPlatform()))
363                      .collect(Collectors.toList()));
364
365        return filteredOptions;
366    }
367    
368    /**
369     * Returns the {@link Option Options} that match the given 
370     * {@link Type Types}. 
371     * 
372     * @param types Desired {@code Option} types. Cannot be {@code null}.
373     * 
374     * @return Either the {@code List} of {@code Option}-s that match the given 
375     * types or an empty {@code List}.
376     */
377    public List<Option> optionsByType(final Collection<Type> types) {
378        if (types == null) {
379            throw new NullPointerException("must specify types");
380        }
381        Collection<Option> allOptions = getAllOptions();
382        List<Option> filteredOptions = 
383            new ArrayList<>(allOptions.size());
384        filteredOptions.addAll(
385            allOptions.stream()
386                      .filter(option -> types.contains(option.getOptionType()))
387                      .collect(Collectors.toList()));
388        return filteredOptions;
389    }
390    
391    /**
392     * Returns the {@link Option Options} that match the given levels of 
393     * {@link Visibility visibility}.
394     * 
395     * @param visibilities Desired visibility levels. Cannot be {@code null}.
396     * 
397     * @return Either the {@code List} of {@code Option}-s that match the given 
398     * visibility levels or an empty {@code List}. 
399     */
400    public List<Option> optionsByVisibility(
401        final Collection<Visibility> visibilities) 
402    {
403        if (visibilities == null) {
404            throw new NullPointerException("must specify visibilities");
405        }
406        Collection<Option> allOptions = getAllOptions();
407        List<Option> filteredOptions = 
408            new ArrayList<>(allOptions.size());
409        filteredOptions.addAll(
410            allOptions.stream()
411                      .filter(option ->
412                              visibilities.contains(option.getOptionVisibility()))
413                      .collect(Collectors.toList()));
414        return filteredOptions;
415    }
416    
417    public void normalizeUserDirectory() {
418        StartupManager startup = StartupManager.getInstance();
419        Platform platform = startup.getPlatform();
420        File dir = new File(platform.getUserDirectory());
421        File prefs = new File(platform.getUserPrefs());
422        
423        if (!dir.exists()) {
424            dir.mkdir();
425        }
426        if (!prefs.exists()) {
427            try {
428                File defaultPrefs = new File(platform.getDefaultPrefs());
429                startup.copy(defaultPrefs, prefs);
430            } catch (IOException e) {
431                System.err.println("Non-fatal error copying user preference template: "+e.getMessage());
432            }
433        }
434    }
435    
436    public void readStartup() {
437        File script =
438            new File(StartupManager.getInstance().getPlatform().getUserPrefs());
439        try (BufferedReader br = new BufferedReader(new FileReader(script))) {
440            String line;
441            while ((line = br.readLine()) != null) {
442                if (line.startsWith("#")) {
443                    continue;
444                }
445                int splitAt = line.indexOf('=');
446                if (splitAt >= 0) {
447                    String id = line.substring(0, splitAt).replace(SET_PREFIX, EMPTY_STRING);
448                    Option option = getOption(id);
449                    if (option != null) {
450                        System.err.println("setting '"+id+"' with '"+line+'\'');
451                        option.fromPrefsFormat(line);
452                    } else {
453                        System.err.println("Warning: Unknown ID '"+id+'\'');
454                    }
455                } else {
456                    System.err.println("Warning: Bad line format '"+line+'\'');
457                }
458            }
459        } catch (IOException e) {
460            System.err.println("Non-fatal error reading the user preferences: "+e.getMessage());
461        }
462    }
463    
464    public void writeStartup() {
465        File script = 
466            new File(StartupManager.getInstance().getPlatform().getUserPrefs());
467        if (script.getPath().isEmpty()) {
468            return;
469        }
470        // TODO(jon): use filters when you've made 'em less stupid
471        String newLine = 
472                StartupManager.getInstance().getPlatform().getNewLine();
473        OptionPlatform currentPlatform = convertToOptionPlatform();
474        StringBuilder contents = new StringBuilder(2048);
475        for (Object[] arrayOption : blahblah) {
476            Option option = getOption((String)arrayOption[0]);
477            OptionPlatform platform = option.getOptionPlatform();
478            if ((platform == OptionPlatform.ALL) || (platform == currentPlatform)) {
479                contents.append(option.toPrefsFormat()).append(newLine);
480            }
481        }
482        
483        try (BufferedWriter out = new BufferedWriter(new FileWriter(script))) {
484            out.write(contents.toString());
485        } catch (IOException e) {
486            logger.error("Could not write to McIDAS-V startup prefs file", e);
487        }
488    }
489}