diff --git a/.gitignore b/.gitignore index 1794ebeac4..37bfb97e02 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ etc/opt doc venv metomi_rose.egg-info +build dist node_modules diff --git a/ACKNOWLEDGEMENT.md b/ACKNOWLEDGEMENT.md index d20dff5a3c..86e4319b2d 100644 --- a/ACKNOWLEDGEMENT.md +++ b/ACKNOWLEDGEMENT.md @@ -3,15 +3,15 @@ Licences for non-Rose works included in this distribution can be found in the licences/ directory. -etc/images/rose-icon.png, -etc/images/rose-icon.svg, -etc/images/rose-icon-trim.png, -etc/images/rose-icon-trim.svg, -etc/images/rose-logo.png, -etc/images/rosie-icon.png, -etc/images/rosie-icon.svg, -etc/images/rosie-icon-trim.png, -etc/images/rosie-icon-trim.svg, +metomi/rose/etc/images/rose-icon.png, +metomi/rose/etc/images/rose-icon.svg, +metomi/rose/etc/images/rose-icon-trim.png, +metomi/rose/etc/images/rose-icon-trim.svg, +metomi/rose/etc/images/rose-logo.png, +metomi/rose/etc/images/rosie-icon.png, +metomi/rose/etc/images/rosie-icon.svg, +metomi/rose/etc/images/rosie-icon-trim.png, +metomi/rose/etc/images/rosie-icon-trim.svg, metomi/rose/etc/rose-all/etc/images/icon.png, metomi/rose/etc/rose-meta/rose-all/etc/images/icon.png * These icons are all derived from the public domain image at diff --git a/README.md b/README.md index 0694f43e9f..c7f496c865 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Rose: a framework for managing and running meteorological suites. #### Rose 2 - Python 3 -- No GUIs +- PyGObject GUI - Web-based GUIs will follow in later Rose 2 releases - `master` branch in the source code diff --git a/demo/rose-config-edit/demo_meta/app/01-types/meta/rose-meta.conf b/demo/rose-config-edit/demo_meta/app/01-types/meta/rose-meta.conf index aae0b7800d..7640ced683 100644 --- a/demo/rose-config-edit/demo_meta/app/01-types/meta/rose-meta.conf +++ b/demo/rose-config-edit/demo_meta/app/01-types/meta/rose-meta.conf @@ -177,7 +177,7 @@ values=1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 description=A variable with 5 values (& value-titles) using radiobuttons value-titles=A title, B title, C title, D title, E title values='a', 'b', 'c', 'd', 'e' -widget[rose-config-edit]=rose.config_editor.valuewidget.radiobuttons.RadioButtonsValueWidget +widget[rose-config-edit]=metomi.rose.config_editor.valuewidget.radiobuttons.RadioButtonsValueWidget [namelist:nl2] duplicate=true @@ -194,7 +194,7 @@ values=4 [namelist:table_nl] description=A page containing a custom table layout -widget[rose-config-edit]=rose.config_editor.pagewidget.table.PageArrayTable +widget[rose-config-edit]=metomi.rose.config_editor.pagewidget.table.PageArrayTable [namelist:table_nl=my_boolean_array] description=A boolean array of length 5 diff --git a/metomi/rose/config_editor/__init__.py b/metomi/rose/config_editor/__init__.py new file mode 100644 index 0000000000..c9a44ac357 --- /dev/null +++ b/metomi/rose/config_editor/__init__.py @@ -0,0 +1,796 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +r"""This package contains the code for the Rose config editor. + +This module contains constants that are only used in the config editor. + +To override constants at runtime, place a section: + +[rose-config-edit] + +in your site or user configuration file for Rose, convert the name +of the constants to lowercase, and place constant=value lines in the +section. For example, to override the "ACCEL_HELP_GUI" constant, you +could put the following in your site or user configuration: + +[rose-config-edit] +accel_help_gui="H" + +The values you enter will be cast by Python's ast.literal_eval, so: +foo=100 +will be cast to an integer, but: +bar="100" +will be cast to a string. + +Generate the user config options help by extracting the 'User-relevant' +flagged blocks of text, e.g. via: + +sed -n '/^# User-relevant: /,/^$/p' __init__.py | \ + sed -n '/User-relevant/d; /^$/d; N; '\ +'s/^# \(.*\)\n\(^[^#].*\) = \(.*\)/'\ +'\

\2\E=\3\<\/h4\>\\1\<\/p\>\n/p;' | sort + +Use this text to update the doc/etc/rose-rug-config-edit/metomi.rose.conf.html +text, remembering to add the [rose-config-edit] section. + +""" + +import ast +import sys + +from metomi.rose.resource import ResourceLocator + +# Accelerators +# Keyboard shortcut mappings. +ACCEL_NEW = "N" +ACCEL_OPEN = "O" +ACCEL_SAVE = "S" +ACCEL_QUIT = "Q" +ACCEL_UNDO = "Z" +ACCEL_REDO = "Z" +ACCEL_FIND = "F" +ACCEL_FIND_NEXT = "G" +ACCEL_METADATA_REFRESH = "F5" +ACCEL_BROWSER = "B" +ACCEL_TERMINAL = "T" +ACCEL_HELP_GUI = "F1" +ACCEL_REMOVE = "Delete" +ACCEL_IGNORE = "I" + +# Menu or panel strings +ADD_MENU_BLANK = "Add blank variable" +ADD_MENU_BLANK_MULTIPLE = "Add blank variable..." +ADD_MENU_META = "Add latent variable" +ICON_PATH_SCHEDULER = None +TAB_MENU_CLOSE = "Close" +TAB_MENU_HELP = "Help" +TAB_MENU_EDIT = "Edit comments" +TAB_MENU_INFO = "Info" +TAB_MENU_OPEN_NEW = "Open in a new window" +TAB_MENU_WEB_HELP = "Web Help" +TOP_MENU_FILE = "_File" +TOP_MENU_FILE_CHECK_AND_SAVE = "_Check And Save" +TOP_MENU_FILE_LOAD_APPS = "_Load All Apps" +TOP_MENU_FILE_NEW = "_New" +TOP_MENU_FILE_OPEN = "_Open..." +TOP_MENU_FILE_SAVE = "_Save" +TOP_MENU_FILE_CLOSE = "_Close" +TOP_MENU_FILE_QUIT = "_Quit" +TOP_MENU_EDIT = "_Edit" +TOP_MENU_EDIT_UNDO = "_Undo" +TOP_MENU_EDIT_REDO = "_Redo" +TOP_MENU_EDIT_STACK = "Undo/Redo _Viewer" +TOP_MENU_EDIT_FIND = "_Find..." +TOP_MENU_EDIT_FIND_NEXT = "_Find Next" +TOP_MENU_EDIT_PREFERENCES = "_Preferences" +TOP_MENU_VIEW = "_View" +TOP_MENU_VIEW_LATENT_VARS = "View _Latent Variables" +TOP_MENU_VIEW_FIXED_VARS = "View _Fixed Variables" +TOP_MENU_VIEW_IGNORED_VARS = "View All _Ignored Variables" +TOP_MENU_VIEW_USER_IGNORED_VARS = "View _User Ignored Variables" +TOP_MENU_VIEW_LATENT_PAGES = "View Latent _Pages" +TOP_MENU_VIEW_IGNORED_PAGES = "View All _Ignored Pages" +TOP_MENU_VIEW_USER_IGNORED_PAGES = "View _User Ignored Pages" +TOP_MENU_VIEW_WITHOUT_DESCRIPTIONS = "Hide Variable Descriptions" +TOP_MENU_VIEW_WITHOUT_HELP = "Hide Variable Help" +TOP_MENU_VIEW_WITHOUT_TITLES = "Hide Variable _Titles" +TOP_MENU_VIEW_CUSTOM_DESCRIPTIONS = "Use Custom _Description Format" +TOP_MENU_VIEW_CUSTOM_HELP = "Use Custom _Help Format" +TOP_MENU_VIEW_CUSTOM_TITLES = "Use Custom _Title Format" +TOP_MENU_VIEW_FLAG_OPT_CONF_VARS = "Flag Opt _Config Variables" +TOP_MENU_VIEW_FLAG_OPTIONAL_VARS = "Flag _Optional Variables" +TOP_MENU_VIEW_FLAG_NO_METADATA_VARS = "Flag _No-metadata Variables" +TOP_MENU_VIEW_STATUS_BAR = "View _Status Bar" +TOP_MENU_PAGE = "_Page" +TOP_MENU_PAGE_ADD = "_Add" +TOP_MENU_PAGE_REVERT = "_Revert to Saved" +TOP_MENU_PAGE_INFO = "_Info" +TOP_MENU_PAGE_HELP = "_Help" +TOP_MENU_PAGE_WEB_HELP = "_Web Help" +TOP_MENU_METADATA = "_Metadata" +TOP_MENU_METADATA_CHECK = "_Check fail-if, warn-if" +TOP_MENU_METADATA_GRAPH = "_Graph Metadata" +TOP_MENU_METADATA_MACRO_ALL_V = "Check All _Validator Macros" +TOP_MENU_METADATA_MACRO_AUTOFIX = "_Auto-fix all configurations" +TOP_MENU_METADATA_MACRO_CONFIG = "{0}" +TOP_MENU_METADATA_PREFERENCES = "Layout _Preferences" +TOP_MENU_METADATA_REFRESH = "_Refresh Metadata" +TOP_MENU_METADATA_LOAD = "Metadata Search Path..." +TOP_MENU_METADATA_SWITCH_OFF = "_Switch off Metadata" +TOP_MENU_METADATA_UPGRADE = "_Upgrade..." +TOP_MENU_TOOLS = "_Tools" +TOP_MENU_TOOLS_BROWSER = "Launch _File Browser" +TOP_MENU_TOOLS_TERMINAL = "Launch _Terminal" +TOP_MENU_TOOLS_VIEW_OUTPUT = "View _Output" +TOP_MENU_HELP = "_Help" +TOP_MENU_HELP_GUI = "_Documentation" +TOP_MENU_HELP_ABOUT = "_About" +TOOLBAR_CHECK_AND_SAVE = "Check and save" +TOOLBAR_LOAD_APPS = "Load All Apps" +TOOLBAR_NEW = "New" +TOOLBAR_OPEN = "Open..." +TOOLBAR_SAVE = "Save" +TOOLBAR_BROWSE = "Browse files" +TOOLBAR_UNDO = "Undo" +TOOLBAR_REDO = "Redo" +TOOLBAR_ADD = "Add to page..." +TOOLBAR_REVERT = "Revert page to saved" +TOOLBAR_FIND = "Find expression (regex)" +TOOLBAR_FIND_NEXT = "Find next" +TOOLBAR_TRANSFORM = "Auto-fix configurations (run built-in transform macros)" +TOOLBAR_VALIDATE = "Check fail-if, warn-if, and run all validator macros" +TREE_PANEL_TITLE = "Index" +TREE_PANEL_ADD_GENERIC = "_Add a new section..." +TREE_PANEL_ADD_SECTION = "_Add {0}" +TREE_PANEL_AUTOFIX_CONFIG = "_Auto-fix configuration" +TREE_PANEL_CLONE_SECTION = "_Clone this section" +TREE_PANEL_EDIT_SECTION = "Edit section comments..." +TREE_PANEL_ENABLE_GENERIC = "_Enable a section..." +TREE_PANEL_ENABLE_SECTION = "_Enable" +TREE_PANEL_GRAPH_SECTION = "_Graph Metadata" +TREE_PANEL_IGNORE_GENERIC = "_Ignore a section..." +TREE_PANEL_IGNORE_SECTION = "_Ignore" +TREE_PANEL_INFO_SECTION = "I_nfo" +TREE_PANEL_HELP_SECTION = "_Help" +TREE_PANEL_NEW_CONFIG = "_Create new configuration..." +TREE_PANEL_REMOVE_GENERIC = "Remove a section..." +TREE_PANEL_REMOVE_SECTION = "_Remove" +TREE_PANEL_RENAME_GENERIC = "Rename a section..." +TREE_PANEL_RENAME_SECTION = "_Rename" +TREE_PANEL_URL_SECTION = "_Web Help" +TREE_PANEL_KBD_TIMEOUT = 600 +MACRO_MENU_ALL_VALIDATORS = "All Validators" +MACRO_MENU_ALL_VALIDATORS_TIP = "Run all available validator macros." +VAR_MENU_ADD = "_Add to configuration" +VAR_MENU_EDIT_COMMENTS = "Edit _comments" +VAR_MENU_FIX_IGNORE = "Auto-Fix Error" +VAR_MENU_ENABLE = "_Enable" +VAR_MENU_HELP = "_Help" +VAR_MENU_IGNORE = "_User-Ignore" +VAR_MENU_INFO = "I_nfo" +VAR_MENU_REMOVE = "_Remove" +VAR_MENU_URL = "_Web Help" +# Button strings +LABEL_EDIT = "edit" +LABEL_PAGE_HELP = "Page help" +LABEL_PAGE_MACRO_BUTTON = "Macros" + +# Loading strings +EVENT_LOAD_CONFIG = "{0} - reading " +EVENT_LOAD_DONE = "{0} - loading GUI" +EVENT_LOAD_ERRORS = "{0} - errors: {1}" +EVENT_LOAD_METADATA = "{0} - configuring" +EVENT_LOAD_STATUSES = "{0} - checking " +LOAD_NUMBER_OF_EVENTS = 2 + +# Other event strings +EVENT_FOUND_ID = "Found {0}" +EVENT_INVALID_TRIGGERS = "{0}: triggers disabled" +EVENT_LOAD_ATTEMPT = "Attempting to load {0}" +EVENT_LOADED = "Loaded {0}" +EVENT_MACRO_CONFIGS = "{0} configurations" +EVENT_MACRO_TRANSFORM = "{1}: {0}: {2} changes" +EVENT_MACRO_TRANSFORM_ALL = "Transforms: {0}: {1} changes" +EVENT_MACRO_TRANSFORM_ALL_OK = "Transforms: {0}: no changes" +EVENT_MACRO_TRANSFORM_OK = "{1}: {0}: no changes" +EVENT_MACRO_VALIDATE = "{1}: {0}: {2} errors" +EVENT_MACRO_VALIDATE_ALL = "Custom Validators: {0}: {1} errors" +EVENT_MACRO_VALIDATE_ALL_OK = "Custom Validators: {0}: all OK" +EVENT_MACRO_VALIDATE_CHECK_ALL = ( + "Custom Validators, FailureRuleChecker: {0} total problems found" +) +EVENT_MACRO_VALIDATE_CHECK_ALL_OK = ( + "Custom Validators, FailureRuleChecker: No problems found" +) +EVENT_MACRO_VALIDATE_OK = "{1}: {0} is OK" +EVENT_MACRO_VALIDATE_NO_PROBLEMS = "Custom Validators: No problems found" +EVENT_MACRO_VALIDATE_PROBLEMS_FOUND = "Custom Validators: {0} problems found" +EVENT_MACRO_VALIDATE_RULE_NO_PROBLEMS = "FailureRuleChecker: No problems found" +EVENT_MACRO_VALIDATE_RULE_PROBLEMS_FOUND = ( + "FailureRuleChecker: {0} problems found" +) +EVENT_REDO = "{0}" +EVENT_REVERT = "Reverted {0}" +EVENT_TIME = "%H:%M:%S" +EVENT_TIME_LONG = "%Y-%m-%dT%H:%M:%S" +EVENT_UNDO = "{0}" +EVENT_UNDO_ACTION_ID = "{0} {1}" + +# Widget strings + +CHOICE_LABEL_EMPTY = "(empty)" +CHOICE_MENU_REMOVE = "Remove from list" +CHOICE_TIP_ENTER_CUSTOM = "Enter a custom choice" +CHOICE_TITLE_AVAILABLE = "Available" +CHOICE_TITLE_INCLUDED = "Included" + +# Error and warning strings +ERROR_ADD_FILE = "Could not add file {0}: {1}" +ERROR_BAD_FIND = "Bad search expression" +ERROR_BAD_NAME = "{0}: invalid name" +ERROR_BAD_MACRO_EXCEPTION = "Could not apply macro: error: {0}: {1}" +ERROR_BAD_MACRO_RETURN = "Bad return value for macro: {0}" +ERROR_BAD_TRIGGER = ( + "{0}\nfor {1}\n" + "from the configuration {2}. " + "\nDisabling triggers for this configuration." +) +ERROR_CONFIG_CREATE = ( + "Error creating application config at {0}:" + "\n {1}, {2}" +) +ERROR_CONFIG_CREATE_TITLE = "Error in creating configuration" +ERROR_CONFIG_DELETE = ( + "Error deleting application config at {0}:" + "\n {1}, {2}" +) +ERROR_CONFIG_DELETE_TITLE = "Error in deleting configuration" +ERROR_ID_NOT_FOUND = "Could not find resource: {0}" +ERROR_FILE_DELETE_FAILED = "Delete failed. {0}" +ERROR_IMPORT_CLASS = "Could not retrieve class {0}" +ERROR_IMPORT_WIDGET = "Could not import widget: {0}" +ERROR_IMPORT_WIDGET_TITLE = "Error importing widget." +ERROR_LOAD_OPT_CONFS = "Could not load optional configurations:\n{0}" +ERROR_LOAD_OPT_CONFS_FORMAT = "{0}\n {1}: {2}\n" +ERROR_LOAD_OPT_CONFS_TITLE = "Error loading opt configs" +ERROR_LOAD_SYNTAX = "Could not load path: {0}\n\nSyntax error:\n{0}\n{1}" +ERROR_METADATA_CHECKER_TITLE = "Flawed metadata warning" +ERROR_METADATA_CHECKER_TEXT = ( + "{0} problem(s) found in metadata at {1}.\n" + + "Some functionality has been switched off.\n\n" + + "Run rose metadata-check for more info." +) +ERROR_NO_OUTPUT = "No output found for {0}" +ERROR_NOT_FOUND = "Could not find path: {0}" +ERROR_NOT_REGEX = "Could not compile expression: {0}\nError info: {1}" +ERROR_ORPHAN_SECTION = "Orphaned section: {0} will not be output at runtime." +ERROR_ORPHAN_SECTION_TIP = "Error: orphaned section!" +ERROR_REMOVE_FILE = "Could not remove file {0}: {1}" +ERROR_RUN_MACRO_TITLE = "Error in running {0}" +ERROR_SECTION_ADD = "Could not add section, already exists: {0}" +ERROR_SECTION_ADD_TITLE = "Error in adding section" +ERROR_SAVE_PATH_FAIL = "Could not save to path!\n {0}" +ERROR_SAVE_BLANK = "Cannot save configuration {0}.\nUnnamed variable in {1}" +ERROR_SAVE_TITLE = "Error saving {0}" +ERROR_UPGRADE = "Error: cannot upgrade {0}" +IGNORED_STATUS_CONFIG = "from configuration." +IGNORED_STATUS_DEFAULT = "from default." +IGNORED_STATUS_MANUAL = "from manual intervention." +IGNORED_STATUS_MACRO = "from macro." +PAGE_WARNING = "Error ({0}): {1}" +PAGE_WARNING_IGNORED_SECTION = "Ignored section: {0}" +PAGE_WARNING_IGNORED_SECTION_TIP = "Ignored section" +PAGE_WARNING_LATENT = "Latent page - no data" +PAGE_WARNING_NO_CONTENT = "Blank page - no data" +PAGE_WARNING_NO_CONTENT_TIP = ( + "No associated configuration or summary data " + "for this page." +) +WARNING_APP_CONFIG_CREATE = "Cannot create another configuration here." +WARNING_APP_CONFIG_CREATE_TITLE = "Warning - application configuration." +WARNING_CONFIG_DELETE = ( + "Cannot remove a whole configuration:\n{0}\n" + + "This must be done externally." +) +WARNING_CONFIG_DELETE_TITLE = "Can't remove configuration" +WARNING_ERRORS_FOUND_ON_SAVE = "Errors found in {0}. Save anyway?" +WARNING_FILE_DELETE = ( + "Not a configuration file entry!\n" + + "This file must be manually removed" + + " in the filesystem:\n {0}." +) +WARNING_FILE_DELETE_TITLE = "Can't remove filesystem file" +WARNING_CANNOT_ENABLE = "Warning - cannot override a trigger setting: {0}" +WARNING_CANNOT_ENABLE_TITLE = "Warning - can't enable" +WARNING_CANNOT_IGNORE = "Warning - cannot override a trigger setting: {0}" +WARNING_CANNOT_IGNORE_TITLE = "Warning - can't ignore" +WARNING_CANNOT_GRAPH = "Warning - graphing not possible" +WARNING_CANNOT_USER_IGNORE = "Warning - cannot override this setting: {0}" +WARNING_NOT_ENABLED = "Should be enabled from " +WARNING_NOT_FOUND = "No results" +WARNING_NOT_FOUND_TITLE = "Couldn't find it" +WARNING_NOT_IGNORED = "Should be ignored " +WARNING_NOT_TRIGGER = "Not part of the trigger mechanism" +WARNING_USER_NOT_TRIGGER_IGNORED = ( + "User-ignored, but should be trigger-ignored" +) +WARNING_NOT_USER_IGNORABLE = "User-ignored, but is compulsory" +WARNING_TYPE_ENABLED = "enabled" +WARNING_TYPE_TRIGGER_IGNORED = "trigger-ignored" +WARNING_TYPE_USER_IGNORED = "user-ignored" +WARNING_TYPE_NOT_TRIGGER = "trigger" +WARNING_TYPES_IGNORE = [ + WARNING_TYPE_ENABLED, + WARNING_TYPE_TRIGGER_IGNORED, + WARNING_TYPE_USER_IGNORED, + WARNING_TYPE_NOT_TRIGGER, +] +WARNING_INTEGER_OUT_OF_BOUNDS = "Warning: integer out of bounds" + +# Special metadata "type" values +FILE_TYPE_FORMATS = "formats" +FILE_TYPE_INTERNAL = "file_int" +FILE_TYPE_NORMAL = "file" +FILE_TYPE_TOP = "suite" + +META_PROP_INTERNAL = "_internal" + +# Setting visibility modes +SHOW_MODE_CUSTOM_DESCRIPTION = "custom-description" +SHOW_MODE_CUSTOM_HELP = "custom-help" +SHOW_MODE_CUSTOM_TITLE = "custom-title" +SHOW_MODE_FIXED = "fixed" +SHOW_MODE_FLAG_NO_META = "flag:no-meta" +SHOW_MODE_FLAG_OPT_CONF = "flag:optional-conf" +SHOW_MODE_FLAG_OPTIONAL = "flag:optional" +SHOW_MODE_IGNORED = "ignored" +SHOW_MODE_USER_IGNORED = "user-ignored" +SHOW_MODE_LATENT = "latent" +SHOW_MODE_NO_DESCRIPTION = "description" +SHOW_MODE_NO_HELP = "help" +SHOW_MODE_NO_TITLE = "title" + +# User-relevant: Defaults for the view and layout modes. +# Control showing a custom variable description format. +SHOULD_SHOW_CUSTOM_DESCRIPTION = False +# Control showing a custom variable help format. +SHOULD_SHOW_CUSTOM_HELP = False +# Control showing a custom variable title format. +SHOULD_SHOW_CUSTOM_TITLE = False +# Control flagging no-metadata variables. +SHOULD_SHOW_FLAG_NO_META_VARS = False +# Control flagging optional configuration variables. +SHOULD_SHOW_FLAG_OPT_CONF_VARS = True +# Control flagging non-compulsory variables. +SHOULD_SHOW_FLAG_OPTIONAL_VARS = False +# Control showing all comment text. +SHOULD_SHOW_ALL_COMMENTS = False +# Control showing fixed variables on a page. +SHOULD_SHOW_FIXED_VARS = True +# Control showing ! or !! ignored pages. +SHOULD_SHOW_IGNORED_PAGES = False +# Control showing ! or !! ignored variables on a page. +SHOULD_SHOW_IGNORED_VARS = False +# Control showing ! ignored pages. +SHOULD_SHOW_USER_IGNORED_PAGES = True +# Control showing ! ignored variables on a page. +SHOULD_SHOW_USER_IGNORED_VARS = True +# Control showing latent (potential) pages. +SHOULD_SHOW_LATENT_PAGES = False +# Control showing latent (potential) variables on a page. +SHOULD_SHOW_LATENT_VARS = False +# Control hiding the description text for variables on a page. +SHOULD_SHOW_NO_DESCRIPTION = False +# Control hiding the help text for variables on a page. +SHOULD_SHOW_NO_HELP = True +# Control hiding the title text for variables on a page. +SHOULD_SHOW_NO_TITLE = False +# Control showing the status bar. +SHOULD_SHOW_STATUS_BAR = True + +# User-relevant: Custom format strings for variable metadata display. +# Metadata representation strings: +# {name} gets replaced with the data/metadata property name. +# For example, you may want to have the description format as: +# "{name} - {description}" +# Configure the override custom format used for description. +CUSTOM_FORMAT_DESCRIPTION = "{name}: {description}" +# Configure the override custom format used for help. +CUSTOM_FORMAT_HELP = "{title}\n\n{help}" +# Configure the override custom format used for title. +CUSTOM_FORMAT_TITLE = "{title} ({name})" + +# User-relevant: Window sizing +# Configure the width of the LHS page tree in pixels. +WIDTH_TREE_PANEL = 256 +# Configure the width and height of the macro dialog in pixels (as a tuple). +SIZE_MACRO_DIALOG_MAX = (800, 600) +# Configure the width and height of the stack viewer in pixels (as a tuple). +SIZE_STACK = (800, 600) +# Configure the width and height of a detached tab in pixels (as a tuple). +SIZE_PAGE_DETACH = (650, 600) +# Configure the width and height of the config editor in pixels (as a tuple). +SIZE_WINDOW = (900, 600) +# Configure the pixel padding/spacing between config editor major items. +SPACING_PAGE = 10 +# Configure the pixel padding/spacing between config editor minor items. +SPACING_SUB_PAGE = 5 + +# Status bar configuration +STATUS_BAR_CONSOLE_TIP = "View more messages (Console)" +STATUS_BAR_CONSOLE_CATEGORY_ERROR = "Error" +STATUS_BAR_CONSOLE_CATEGORY_INFO = "Info" +STATUS_BAR_MESSAGE_LIMIT = 1000 +STATUS_BAR_VERBOSITY = 0 # Compare with metomi.rose.reporter.Reporter. + +# Stack action names and presentation +STACK_GROUP_ADD = "Add" +STACK_GROUP_COPY = "Copy" +STACK_GROUP_IGNORE = "Ignore" +STACK_GROUP_DELETE = "Delete" +STACK_GROUP_RENAME = "Rename" +STACK_GROUP_REORDER = "Reorder" + +STACK_ACTION_ADDED = "Added" +STACK_ACTION_APPLIED = "Applied" +STACK_ACTION_CHANGED = "Changed" +STACK_ACTION_CHANGED_COMMENTS = "Changed #" +STACK_ACTION_ENABLED = "Enabled" +STACK_ACTION_IGNORED = "Ignored" +STACK_ACTION_REMOVED = "Removed" +STACK_ACTION_REVERSED = "Reversed" + +# User-relevant: Undo/Redo Stack Viewer Colours +# Configure the colour for 'added' action. +COLOUR_STACK_ADDED = "green" +# Configure the colour for 'applied a diff' action. +COLOUR_STACK_APPLIED = "green" +# Configure the colour for 'changed' action. +COLOUR_STACK_CHANGED = "blue" +# Configure the colour for 'changed comments' action. +COLOUR_STACK_CHANGED_COMMENTS = "dark blue" +# Configure the colour for 'enabled' action. +COLOUR_STACK_ENABLED = "light green" +# Configure the colour for 'ignore' action. +COLOUR_STACK_IGNORED = "grey" +# Configure the colour for 'remove' action. +COLOUR_STACK_REMOVED = "red" +# Configure the colour for 'revert a diff' action. +COLOUR_STACK_REVERSED = "red" + +# User-relevant: Macro Dialog Colours +# Configure the colour for 'changed' action. +COLOUR_MACRO_CHANGED = "blue" +# Configure the colour for an error. +COLOUR_MACRO_ERROR = "red" +# Configure the colour for a warning. +COLOUR_MACRO_WARNING = "orange" + +STACK_COL_NS = "Namespace" +STACK_COL_ACT = "Action" +STACK_COL_NAME = "Name" +STACK_COL_VALUE = "Value" +STACK_COL_OLD_VALUE = "Old Value" + +# User-relevant: Variable Widget Colours. +# Configure the background colour for the currently-selected variable widget. +COLOUR_VALUEWIDGET_BASE_SELECTED = "GhostWhite" +# Configure the modified-status variable widget colour. +COLOUR_VARIABLE_CHANGED = "blue" +# Configure the error-state variable widget colour. +COLOUR_VARIABLE_TEXT_ERROR = "dark red" +# Configure the irrelevant-value variable widget colour. +COLOUR_VARIABLE_TEXT_IRRELEVANT = "light grey" +# Configure the environment-variable-value variable widget colour. +COLOUR_VARIABLE_TEXT_VAL_ENV = "purple4" + +# Dialog text +DIALOG_BODY_ADD_CONFIG = "Choose configuration to add to" +DIALOG_BODY_ADD_SECTION = "Specify new configuration section name" +DIALOG_BODY_IGNORE_ENABLE_CONFIG = "Choose configuration" +DIALOG_BODY_IGNORE_SECTION = "Choose the section to ignore" +DIALOG_BODY_ENABLE_SECTION = "Choose the section to enable" +DIALOG_BODY_FILE_ADD = "The file {0} will be added at your next save." +DIALOG_BODY_FILE_REMOVE = "The file {0} will be deleted at your next save." +DIALOG_BODY_GRAPH_CONFIG = "Choose the configuration to graph" +DIALOG_BODY_GRAPH_SECTION = "Choose a particular section to graph" +DIALOG_BODY_MACRO_CHANGES = "{0} {1}\n {2}\n" +DIALOG_BODY_MACRO_CHANGES_MAX_LENGTH = 150 # Must > raw CHANGES text above +DIALOG_BODY_MACRO_CHANGES_NUM_HEIGHT = 3 # > Number, needs more height. +DIALOG_BODY_NL_CASE_CHANGE = ( + "Mixed-case names cause trouble in namelists." + "\nSuggested: {0}" +) +DIALOG_BODY_REMOVE_CONFIG = "Choose configuration" +DIALOG_BODY_RENAME_CONFIG = "Choose configuration" +DIALOG_BODY_REMOVE_SECTION = "Choose the section to remove" +DIALOG_BODY_RENAME_SECTION = "Choose the section to rename" +DIALOG_COLUMNS_UPGRADE = ["Name", "Version", "Upgrade Version", "Upgrade?"] +DIALOG_HELP_TITLE = "Help for {0}" +DIALOG_LABEL_AUTOFIX = "Run built-in transform (fixer) macros?" +DIALOG_LABEL_AUTOFIX_ALL = ( + "Run built-in transform (fixer) macros for all configurations?" +) +DIALOG_LABEL_CHOOSE_SECTION_ADD_VAR = "Choose a section for the new variable:" +DIALOG_LABEL_CHOOSE_SECTION_EDIT = "Choose a section to edit:" +DIALOG_LABEL_CONFIG_CHOOSE_META = "Metadata id:" +DIALOG_LABEL_CONFIG_CHOOSE_NAME = "New config name:" +DIALOG_LABEL_MACRO_TRANSFORM_CHANGES = ( + "{0}: {1}\n" + "changes: {2}" +) +DIALOG_LABEL_MACRO_TRANSFORM_NONE = "No configuration changes from this macro." +DIALOG_LABEL_MACRO_VALIDATE_ISSUES = "{0} {1}\n" + "errors: {2}" +DIALOG_LABEL_MACRO_VALIDATE_NONE = "Configuration OK for this macro." +DIALOG_LABEL_MACRO_WARN_ISSUES = "warnings: {0}" +DIALOG_LABEL_NULL_SECTION = "None" +DIALOG_LABEL_PREFERENCES = ( + "Please edit your site and user " + "configurations to make changes." +) +DIALOG_LABEL_UPGRADE = "Click Upgrade Version cells to change target versions." +DIALOG_LABEL_UPGRADE_ALL = "Populate all possible versions" +DIALOG_TIP_SUITE_RUN_HELP = "Read the help for rose suite-run" +DIALOG_TEXT_MACRO_CHANGED = "changed" +DIALOG_TEXT_MACRO_ERROR = "error" +DIALOG_TEXT_MACRO_WARNING = "warning" +DIALOG_TEXT_SUITE_NOT_RUNNING = "Cannot launch gcontrol: {0}" +DIALOG_TEXT_UNREGISTERED_SUITE = ( + "Cannot launch gcontrol: " + "suite {0} is not registered." +) +DIALOG_TITLE_MACRO_TRANSFORM = "{0} - Changes for {1}" +DIALOG_TITLE_MACRO_TRANSFORM_NONE = "{0}" +DIALOG_TITLE_MACRO_VALIDATE = "{0} - Issues for {1}" +DIALOG_TITLE_MACRO_VALIDATE_NONE = "{0}" +DIALOG_TITLE_ADD = "Add section" +DIALOG_TITLE_AUTOFIX = "Automatic fixing" +DIALOG_TITLE_CHOOSE_SECTION = "Choose section" +DIALOG_TITLE_CONFIG_CREATE = "Create configuration" +DIALOG_TITLE_CRITICAL_ERROR = "Error" +DIALOG_TITLE_EDIT_COMMENTS = "Edit comments for {0}" +DIALOG_TITLE_ENABLE = "Enable section" +DIALOG_TITLE_ERROR = "Error" +DIALOG_TITLE_GRAPH = "rose metadata-graph" +DIALOG_TITLE_IGNORE = "Ignore section" +DIALOG_TITLE_INFO = "Information" +DIALOG_TITLE_OPEN = "Open configuration" +DIALOG_TITLE_LOAD_METADATA = "Add search path" +DIALOG_TITLE_MANAGE_METADATA = "Metadata search path" +DIALOG_TITLE_MACRO_CHANGES = "Accept changes made by {0}?" +DIALOG_TITLE_META_LOAD_ERROR = "Error loading metadata." +DIALOG_TITLE_NL_CASE_WARNING = "Mixed-case warning" +DIALOG_TITLE_PREFERENCES = "Configure preferences" +DIALOG_TITLE_REMOVE = "Remove section" +DIALOG_TITLE_RENAME = "Rename section" +DIALOG_TITLE_SAVE_CHANGES = "Save changes?" +DIALOG_TITLE_SUITE_NOT_RUNNING = "Suite not running" +DIALOG_TITLE_UNREGISTERED_SUITE = "Suite not registered" +DIALOG_TITLE_UPGRADE = "Upgrade configurations" +DIALOG_TITLE_WARNING = "Warning" +DIALOG_VARIABLE_ERROR_TITLE = "{0} error for {1}" +DIALOG_VARIABLE_WARNING_TITLE = "{0} warning for {1}" +DIALOG_NODE_INFO_ATTRIBUTE = "{0}" +DIALOG_NODE_INFO_CHANGES = "{0}\n" +DIALOG_NODE_INFO_DATA = "Data\n" +DIALOG_NODE_INFO_DELIMITER = " " +DIALOG_NODE_INFO_METADATA = "Metadata\n" +DIALOG_NODE_INFO_MAX_LEN = 80 +DIALOG_NODE_INFO_SUB_ATTRIBUTE = "{0}:" +STACK_VIEW_TITLE = "Undo and Redo Stack Viewer" + +# Page names + +TITLE_PAGE_IGNORED_MARKUP = "{0} {1}" +TITLE_PAGE_INFO = "suite info" + +# User-relevant: Latent Page Colour +# Configure the colour used to indicate a latent page in the page tree. +TITLE_PAGE_LATENT_COLOUR = "grey" + +TITLE_PAGE_LATENT_MARKUP = ( + "{0}" + + "" +) +TITLE_PAGE_PREVIEW_MARKUP = ( + "{0}" + + "" +) +TITLE_PAGE_ROOT_MARKUP = "{0}" +TITLE_PAGE_SUITE = "suite conf" + +# User-relevant: Page Tree Expansion +# Configure the maximum number of configurations loaded at startup. +TREE_PANEL_MAX_EXPANDED_ROOTS = 5 +# Configure the depth to which the page tree will expand itself. +TREE_PANEL_MAX_EXPANDED_DEPTH = 2 +# Configure a regex for pages whose children should not be expanded. +TREE_PANEL_NO_EXPAND_LEAVES_REGEX = "/file$" + +# File panel names + +FILE_PANEL_EXPAND = 2 +FILE_PANEL_MENU_OPEN = "Open" +TITLE_FILE_PANEL = "Other files" + +# Summary (sub) data panel names + +SUMMARY_DATA_PANEL_ERROR_TIP = "Error ({0}): {1}\n" + +# User-relevant: Summary Data Panel Colours +# Configure the Pango markup used to indicate an error. +SUMMARY_DATA_PANEL_ERROR_MARKUP = "X" +# Configure the Pango markup used to indicate modified state. +SUMMARY_DATA_PANEL_MODIFIED_MARKUP = "*" + +SUMMARY_DATA_PANEL_FILTER_LABEL = "Filter:" +SUMMARY_DATA_PANEL_FILTER_MAX_CHAR = 8 +SUMMARY_DATA_PANEL_GROUP_LABEL = "Group:" +SUMMARY_DATA_PANEL_IGNORED_SECT_MARKUP = "^" +SUMMARY_DATA_PANEL_IGNORED_SYST_MARKUP = "!!" +SUMMARY_DATA_PANEL_IGNORED_USER_MARKUP = "!" +SUMMARY_DATA_PANEL_MAX_LEN = 15 +SUMMARY_DATA_PANEL_MENU_ADD = "Add new section" +SUMMARY_DATA_PANEL_MENU_COPY = "Clone this section" +SUMMARY_DATA_PANEL_MENU_ENABLE = "Enable this section" +SUMMARY_DATA_PANEL_MENU_ENABLE_MULTI = "Enable these sections" +SUMMARY_DATA_PANEL_MENU_GO_TO = "View {0}" +SUMMARY_DATA_PANEL_MENU_IGNORE = "Ignore this section" +SUMMARY_DATA_PANEL_MENU_IGNORE_MULTI = "Ignore these sections" +SUMMARY_DATA_PANEL_MENU_REMOVE = "Remove this section" +SUMMARY_DATA_PANEL_MENU_REMOVE_MULTI = "Remove these sections" +SUMMARY_DATA_PANEL_SECTION_TITLE = "Section" +SUMMARY_DATA_PANEL_INDEX_TITLE = "Index" +FILE_CONTENT_PANEL_FORMAT_LABEL = "Hide available sections" +FILE_CONTENT_PANEL_MENU_OPTIONAL = "Toggle optional status" +FILE_CONTENT_PANEL_OPT_TIP = "Items available for file source" +FILE_CONTENT_PANEL_TIP = "Items included in file source" +FILE_CONTENT_PANEL_TITLE = "Available sections" + +# Tooltip (hover-over) text + +TREE_PANEL_TIP_ADDED_CONFIG = "Added configuration since the last save" +TREE_PANEL_TIP_ADDED_VARS = "Added variable(s) since the last save" +TREE_PANEL_TIP_CHANGED_CONFIG = "Modified since the last save" +TREE_PANEL_TIP_CHANGED_SECTIONS = "Modified section data since the last save" +TREE_PANEL_TIP_CHANGED_VARS = "Modified variable data since the last save" +TREE_PANEL_TIP_DIFF_SECTIONS = "Added/removed sections since the last save" +TREE_PANEL_TIP_REMOVED_VARS = "Removed variable(s) since the last save" + +KEY_TIP_ADDED = "Added since the last save." +KEY_TIP_CHANGED = "Modified since the last save, old value {0}" +KEY_TIP_CHANGED_COMMENTS = "Modified comments since the last save." +KEY_TIP_ENABLED = "Enabled since the last save." +KEY_TIP_SECTION_IGNORED = "Section ignored since the last save." +KEY_TIP_TRIGGER_IGNORED = "Trigger ignored since the last save." +KEY_TIP_MISSING = "Removed since the last save." +KEY_TIP_USER_IGNORED = "User ignored since the last save." +TIP_CONFIG_CHOOSE_META = "Enter a metadata identifier for the new config" +TIP_CONFIG_CHOOSE_NAME = "Enter a directory name for the new config." +TIP_CONFIG_CHOOSE_NAME_ERROR = "Invalid directory name for the new config." +TIP_ADD_TO_PAGE = "Add to page..." +TIP_LATENT_PAGE = "Latent page" +TIP_MACRO_RUN_PAGE = "Choose a macro to run for this page" +TIP_REVERT_PAGE = "Revert page to last save" +TIP_SUITE_RUN_ARG = "Enter extra suite run arguments" +TIP_VALUE_ADD_URI = "Add a URI - for example a file path, or a web url" +TREE_PANEL_ERROR = " (1 error)" +TREE_PANEL_ERRORS = " ({0} errors)" +TREE_PANEL_MODIFIED = " (modified)" +TERMINAL_TIP_CLOSE = "Close terminal" +VAR_COMMENT_TIP = "# {0}" +VAR_FLAG_MARKUP = "{0}" +VAR_FLAG_TIP_FIXED = "Fixed variable (only one allowed value)" +VAR_FLAG_TIP_NO_META = "Flag: no metadata" +VAR_FLAG_TIP_OPT_CONF = "Optional conf overrides:\n{0}" +# Numbers below mean: 0-opt config name, 1-id state/value. +VAR_FLAG_TIP_OPT_CONF_INFO = " {0}: {1}\n" +# Numbers below mean: 0-sect state, 1-sect, 2-opt state, 3-opt, 4-opt value. +VAR_FLAG_TIP_OPT_CONF_STATE = "{0}{1}={2}{3}={4}" +VAR_FLAG_TIP_OPTIONAL = "Flag: optional" +VAR_MENU_TIP_ERROR = "Error " +VAR_MENU_TIP_LATENT = "This variable could be added to the configuration." +VAR_MENU_TIP_WARNING = "Warning " +VAR_MENU_TIP_FIX_IGNORE = "Auto-fix the variable's ignored state error" +VAR_WIDGET_ENV_INFO = "Set to environment variable" + +# Flags for variable widgets + +FLAG_TYPE_DEFAULT = "Default flag" +FLAG_TYPE_ERROR = "Error flag" +FLAG_TYPE_FIXED = "Fixed flag" +FLAG_TYPE_NO_META = "No metadata flag" +FLAG_TYPE_OPT_CONF = "Opt conf override flag" +FLAG_TYPE_OPTIONAL = "Optional flag" + +# Relevant metadata properties + +META_PROP_WIDGET = "widget[rose-config-edit]" +META_PROP_WIDGET_SUB_NS = "widget[rose-config-edit:sub-ns]" + +# Miscellaneous +COPYRIGHT = """Crown Copyright (C) 2012-2024 (Met Office) & Contributors. + For full terms of use and licenses visit the Rose link above.""" +ABOUT_TEXT = "GUI interface to edit rose suites." +CREDIT = ( + ["Ben Fitzpatrick", ["Principal Developer"]], + ["Dimitrios Theodorakis", ["Migration to Rose 2.0"]], + ["Joseph Abram", ["Migration to Rose 2.0"]], +) +HELP_FILE = "rose-rug-config-edit.html" +LAUNCH_COMMAND = "rose config-edit" +LAUNCH_COMMAND_CONFIG = "rose config-edit -C" +LAUNCH_COMMAND_GRAPH = "rose metadata-graph -C" +LAUNCH_SUITE_RUN = "rose suite-run" +LAUNCH_SUITE_RUN_HELP = "rose help suite-run" +MAX_APPS_THRESHOLD = 10 +PROGRAM_NAME = "rose edit" +PROJECT_URL = "http://github.com/metomi/rose/" +UNTITLED_NAME = "Untitled" +VAR_ID_IN_CONFIG = "Variable id {0} from the configuration {1}" + + +_OVERRIDE_WARNING_PRIVATE = "Cannot override: {0}={1} ({2}): not permitted.\n" +_OVERRIDE_WARNING_TYPE = ( + "Cannot override: {0}={1}={2}: site/user conf: was {3}, supplied {4}\n" +) + + +def false_function(*args, **kwargs): + """Return False, no matter what the arguments are.""" + return False + + +def load_override_config(sections, my_globals=None): + if my_globals is None: + my_globals = globals() + for section in sections: + conf = ResourceLocator.default().get_conf().get([section]) + if conf is None: + continue + for key, node in list(conf.value.items()): + if node.is_ignored(): + continue + try: + cast_value = ast.literal_eval(node.value) + except Exception: + cast_value = node.value + name = key.replace("-", "_").upper() + orig_value = my_globals[name] + if ( + not isinstance(orig_value, type(cast_value)) + and orig_value is not None + ): + sys.stderr.write( + _OVERRIDE_WARNING_TYPE.format( + section, + key, + cast_value, + type(orig_value), + type(cast_value), + ) + ) + continue + if name.startswith("_"): + sys.stderr.write( + _OVERRIDE_WARNING_PRIVATE.format(section, key, name) + ) + continue + my_globals[name] = cast_value + + +load_override_config(["rose-config-edit"]) diff --git a/metomi/rose/config_editor/data.py b/metomi/rose/config_editor/data.py new file mode 100644 index 0000000000..de45256647 --- /dev/null +++ b/metomi/rose/config_editor/data.py @@ -0,0 +1,1548 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This module contains: + +VarData -- class to store metomi.rose.variable.Variable instances +SectData -- class to store metomi.rose.section.Section instances +ConfigData -- class to store and process a directory into internal +data structures +ConfigDataManager -- class to load and process objects in ConfigData + +""" + +import copy +import itertools +import glob +import os +import re +import sys + +import metomi.rose.config +import metomi.rose.config_editor.data_helper +import metomi.rose.config_tree +import metomi.rose.gtk.dialog +import metomi.rose.macro +import metomi.rose.metadata_check +import metomi.rose.resource +import metomi.rose.section +import metomi.rose.macros.trigger +import metomi.rose.variable + + +REC_NS_SECTION = re.compile( + r"^(" + metomi.rose.META_PROP_NS + metomi.rose.CONFIG_DELIMITER + r")(.*)$" +) + + +class VarData(object): + """Stores past, present, and missing variables.""" + + def __init__(self, v_map, latent_v_map, save_v_map, latent_save_v_map): + self.now = v_map + self.latent = latent_v_map + self.save = save_v_map + self.latent_save = latent_save_v_map + + def foreach(self, save=False, skip_latent=False): + """Yield all (section, variables) tuples for real and latent.""" + if save: + real = self.save + latent = self.latent_save + else: + real = self.now + latent = self.latent + for section, variables in list(real.items()): + yield section, variables + if not skip_latent: + for section, variables in list(latent.items()): + yield section, variables + + def get_all(self, save=False, skip_latent=False, skip_real=False): + """Return all real and latent variables.""" + if save: + real = self.save + latent = self.latent_save + else: + real = self.now + latent = self.latent + all_vars = [] + if not skip_real: + all_vars += list(itertools.chain(*list(real.values()))) + if not skip_latent: + all_vars += list(itertools.chain(*list(latent.values()))) + return all_vars + + def get_var(self, section, option, save=False, skip_latent=False): + """Return the variable specified by section, option.""" + var_id = section + metomi.rose.CONFIG_DELIMITER + option + if save: + nodes = [self.save, self.latent_save] + else: + nodes = [self.now, self.latent] + if skip_latent: + nodes.pop() + for node in nodes: + for var in node.get(section, []): + if var.metadata["id"] == var_id: + return var + return None + + +class SectData(object): + """Stores past, present, and missing sections.""" + + def __init__( + self, sections, latent_sections, save_sections, latent_save_sections + ): + self.now = sections + self.latent = latent_sections + self.save = save_sections + self.latent_save = latent_save_sections + + def get_all(self, save=False, skip_latent=False, skip_real=False): + """Return all sections that match the save/latent criteria.""" + if save: + real = self.save + latent = self.latent_save + else: + real = self.now + latent = self.latent + all_sections = [] + if not skip_real: + all_sections += list(real.values()) + if not skip_latent: + all_sections += list(latent.values()) + return all_sections + + def get_sect(self, section, save=False, skip_latent=False): + """Return the section data specified by section.""" + if save: + nodes = [self.save, self.latent_save] + else: + nodes = [self.now, self.latent] + if skip_latent: + nodes.pop() + for node in nodes: + if section in node: + return node[section] + return None + + +class ConfigData(object): + """Stores information about a configuration.""" + + def __init__( + self, + config, + s_config, + directory, + opt_conf_lookup, + meta, + meta_id, + meta_files, + macros, + config_type, + var_data=None, + sect_data=None, + is_preview=False, + ): + self.config = config + self.save_config = s_config + self.directory = directory + self.opt_configs = opt_conf_lookup + self.meta = meta + self.meta_id = meta_id + self.meta_files = meta_files + self.macros = macros + self.config_type = config_type + self.vars = var_data + self.sections = sect_data + self.is_preview = is_preview + + +class ConfigDataManager(object): + """Loads the information from the various configurations.""" + + def __init__( + self, + util, + reporter, + page_ns_show_modes, + reload_ns_tree_func, + opt_meta_paths=None, + no_warn=None, + ): + """Load the root configuration and all its sub-configurations.""" + self.util = util + self.helper = metomi.rose.config_editor.data_helper.ConfigDataHelper( + self, util + ) + self.reporter = reporter + self.page_ns_show_modes = page_ns_show_modes + self.reload_ns_tree_func = reload_ns_tree_func + self.config = {} # Stores configuration name: object + self._builtin_value_macro = ( + metomi.rose.macros.value.ValueChecker() + ) # value + self.builtin_macros = {} # Stores other Rose built-in macro instances + self._bad_meta_dir_paths = [] # Stores flawed metadata directories. + self.trigger = {} # Stores trigger macro instances per configuration + self.trigger_id_trees = {} # Stores trigger dependencies + self.trigger_id_value_lookup = {} # Stores old values of trigger vars + self.namespace_meta_lookup = {} # Stores titles etc of namespaces + self.namespace_cached_statuses = { + "latent": {}, + "ignored": {}, + } # Caches ns statuses + self._config_section_namespace_map = {} # Store section namespaces + self.locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + if opt_meta_paths is None: + self.opt_meta_paths = [] + else: + self.opt_meta_paths = opt_meta_paths + self.no_warn = no_warn + self.top_level_directory = None + self.app_count = 0 + self.saved_config_names = None + self.top_level_name = None + + def load( + self, + top_level_directory, + config_obj_dict, + config_obj_type_dict=None, + load_all_apps=False, + load_no_apps=False, + metadata_off=False, + ): + """Load configurations and their metadata.""" + if config_obj_type_dict is None: + config_obj_type_dict = {} + if top_level_directory is not None: + for filename in os.listdir(top_level_directory): + if filename in [ + metomi.rose.TOP_CONFIG_NAME, + metomi.rose.SUB_CONFIG_NAME, + ]: + self.load_top_config( + top_level_directory, + load_all_apps=load_all_apps, + load_no_apps=load_no_apps, + metadata_off=metadata_off, + ) + break + else: + self.load_top_config(None) + elif not config_obj_dict: + self.load_top_config(None) + else: + self.top_level_name = list(config_obj_dict.keys())[0] + self.top_level_directory = None + for name, obj in list(config_obj_dict.items()): + config_type = config_obj_type_dict.get(name) + self.load_config( + config_name=name, config=obj, config_type=config_type + ) + self.saved_config_names = set(self.config.keys()) + + def load_top_config( + self, + top_level_directory, + preview=False, + load_all_apps=False, + load_no_apps=False, + metadata_off=False, + ): + """Load the config at the top level and any sub configs.""" + self.top_level_directory = top_level_directory + + self.app_count = 0 + if top_level_directory is None: + self.top_level_name = metomi.rose.config_editor.UNTITLED_NAME + else: + self.top_level_name = os.path.basename(top_level_directory) + config_container_dir = os.path.join( + top_level_directory, metomi.rose.SUB_CONFIGS_DIR + ) + if os.path.isdir(config_container_dir): + sub_contents = sorted(os.listdir(config_container_dir)) + + if not load_all_apps: + if load_no_apps: + preview = True + else: + for config_dir in sub_contents: + conf_path = os.path.join( + config_container_dir, config_dir + ) + if os.path.isdir( + conf_path + ) and not config_dir.startswith("."): + self.app_count += 1 + + if ( + self.app_count + > metomi.rose.config_editor.MAX_APPS_THRESHOLD + ): + preview = True + + for config_dir in sub_contents: + conf_path = os.path.join(config_container_dir, config_dir) + if os.path.isdir(conf_path) and not config_dir.startswith( + "." + ): + self.load_config( + conf_path, + preview=preview, + metadata_off=metadata_off, + ) + self.load_config(top_level_directory) + self.reload_ns_tree_func() + + def load_info_config(self, config_directory): + """Load any information (discovery) config.""" + disc_path = os.path.join( + config_directory, metomi.rose.INFO_CONFIG_NAME + ) + if os.path.isfile(disc_path): + config_obj = self.load_config_file(disc_path)[0] + self.load_config( + config_name="/" + self.top_level_name + "-info", + config=config_obj, + config_type=metomi.rose.INFO_CONFIG_NAME, + ) + + def load_config( + self, + config_directory=None, + config_name=None, + config=None, + config_type=None, + reload_tree_on=False, + skip_load_event=False, + preview=False, + metadata_off=False, + ): + """Load the configuration and metadata.""" + if config_directory is None: + name = "/" + config_name.lstrip("/") + config = config + s_config = copy.deepcopy(config) + if not skip_load_event: + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_CONFIG.format( + name.lstrip("/") + ) + ) + else: + config_directory = config_directory.rstrip("/") + if config_directory != self.top_level_directory: + # One of the sub configurations + head, tail = os.path.split(config_directory) + name = "" + while tail != metomi.rose.SUB_CONFIGS_DIR: + name = "/" + os.path.join(tail, name).rstrip("/") + head, tail = os.path.split(head) + name = "/" + name.lstrip("/") + config_type = metomi.rose.SUB_CONFIG_NAME + elif metomi.rose.TOP_CONFIG_NAME not in os.listdir( + config_directory + ): + # Just editing a single sub configuration, not a suite + name = "/" + self.top_level_name + config_type = metomi.rose.SUB_CONFIG_NAME + else: + # Make sure we also load any discovery (info) configuration + self.load_info_config(config_directory) + # A suite configuration + name = "/" + self.top_level_name + "-conf" + config_type = metomi.rose.TOP_CONFIG_NAME + if not skip_load_event: + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_CONFIG.format( + name.lstrip("/") + ) + ) + config_path = os.path.join( + config_directory, metomi.rose.SUB_CONFIG_NAME + ) + if not os.path.isfile(config_path): + if os.path.abspath(config_directory) == os.path.abspath( + self.top_level_directory + ): + config_path = os.path.join( + config_directory, metomi.rose.TOP_CONFIG_NAME + ) + config_type = metomi.rose.TOP_CONFIG_NAME + else: + text = metomi.rose.config_editor.ERROR_NOT_FOUND.format( + config_path + ) + title = ( + metomi.rose.config_editor.DIALOG_TITLE_CRITICAL_ERROR + ) + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + sys.exit(2) + + if config_directory != self.top_level_directory and preview: + # Load with empty ConfigNodes for initial app access. + config = metomi.rose.config.ConfigNode() + s_config = metomi.rose.config.ConfigNode() + else: + config, s_config = self.load_config_file(config_path) + + if config_directory != self.top_level_directory and preview: + meta_config_tree = metomi.rose.config_tree.ConfigTree() + elif metadata_off: + meta_config_tree = self.load_meta_config_tree( + config_type=config_type, opt_meta_paths=self.opt_meta_paths + ) + else: + try: + meta_config_tree = self.load_meta_config_tree( + config, + config_directory, + config_type=config_type, + opt_meta_paths=self.opt_meta_paths, + ) + except IOError as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + meta_config_tree = metomi.rose.config_tree.ConfigTree() + + meta_config = meta_config_tree.node + opt_conf_lookup = self.load_optional_configs(config_directory) + + macro_module_prefix = self.helper.get_macro_module_prefix(name) + meta_files = self.load_meta_files(meta_config_tree) + macros = metomi.rose.macro.load_meta_macro_modules( + meta_files, module_prefix=macro_module_prefix + ) + meta_id = self.helper.get_config_meta_flag( + name, from_this_config_obj=config + ) + # Initialise configuration data object. + self.config[name] = ConfigData( + config, + s_config, + config_directory, + opt_conf_lookup, + meta_config, + meta_id, + meta_files, + macros, + config_type, + is_preview=preview, + ) + + self.load_builtin_macros(name) + self.load_file_metadata(name) + self.filter_meta_config(name) + + # Load section and variable data into the object. + sects, l_sects = self.load_sections_from_config(name) + s_sects, s_l_sects = self.load_sections_from_config(name) + self.config[name].sections = SectData( + sects, l_sects, s_sects, s_l_sects + ) + var, l_var, s_var, s_l_var = self.load_vars_from_config( + name, return_copies=True + ) + self.config[name].vars = VarData(var, l_var, s_var, s_l_var) + + if not skip_load_event: + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_METADATA.format( + name.lstrip("/") + ) + ) + # Process namespaces and ignored statuses. + self.load_node_namespaces(name) + self.load_node_namespaces(name, from_saved=True) + self.load_ignored_data(name) + self.load_metadata_for_namespaces(name) + if reload_tree_on: + self.reload_ns_tree_func() + + def load_config_file(self, config_path): + """Return two copies of the + metomi.rose.config.ConfigNode at config_path. + + """ + + try: + config = metomi.rose.config.load(config_path) + except metomi.rose.config.ConfigSyntaxError as exc: + text = metomi.rose.config_editor.ERROR_LOAD_SYNTAX.format( + config_path, exc + ) + title = metomi.rose.config_editor.DIALOG_TITLE_CRITICAL_ERROR + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + sys.exit(2) + else: + master_config = metomi.rose.config.load(config_path) + metomi.rose.macro.standard_format_config(config) + metomi.rose.macro.standard_format_config(master_config) + return config, master_config + + def load_optional_configs(self, config_directory): + """Load any optional configurations.""" + opt_conf_lookup = {} + if config_directory is None: + return opt_conf_lookup + opt_dir = os.path.join( + config_directory, metomi.rose.config.OPT_CONFIG_DIR + ) + if not os.path.isdir(opt_dir): + return opt_conf_lookup + opt_exceptions = {} + opt_glob = os.path.join(opt_dir, metomi.rose.GLOB_OPT_CONFIG_FILE) + for path in glob.glob(opt_glob): + if os.access(path, os.F_OK | os.R_OK): + filename = os.path.basename(path) + # filename is a null string if path is to a directory. + result = re.match(metomi.rose.RE_OPT_CONFIG_FILE, filename) + if not result: + continue + name = result.group(1) + try: + opt_config = metomi.rose.config.load(path) + except Exception as exc: + opt_exceptions.update({path: exc}) + continue + opt_conf_lookup.update({name: opt_config}) + if opt_exceptions: + err_text = "" + err_format = metomi.rose.config_editor.ERROR_LOAD_OPT_CONFS_FORMAT + for path, exc in sorted(opt_exceptions.items()): + err_text += err_format.format(path, type(exc).__name__, exc) + err_text = err_text.rstrip() + text = metomi.rose.config_editor.ERROR_LOAD_OPT_CONFS.format( + err_text + ) + title = metomi.rose.config_editor.ERROR_LOAD_OPT_CONFS_TITLE + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + text, + title=title, + modal=False, + ) + return opt_conf_lookup + + def load_builtin_macros(self, config_name): + """Load Rose builtin macros.""" + self.builtin_macros[config_name] = { + metomi.rose.META_PROP_COMPULSORY: ( + metomi.rose.macros.compulsory.CompulsoryChecker() + ), + metomi.rose.META_PROP_TYPE: self._builtin_value_macro, + } + + def load_sections_from_config(self, config_name, save=False): + """Return maps of section objects from the configuration.""" + sect_map = {} + latent_sect_map = {} + real_sect_ids = [] + real_sect_basic_ids = [] + if save: + config = self.config[config_name].save_config + else: + config = self.config[config_name].config + meta_config = self.config[config_name].meta + for section, node in list(config.value.items()): + if not isinstance(node.value, dict): + if "" in sect_map: + sect_map[""].options.append(section) + continue + meta_data = self.helper.get_metadata_for_config_id( + "", config_name + ) + sect_map.update( + {"": metomi.rose.section.Section("", [section], meta_data)} + ) + real_sect_ids.append("") + continue + meta_data = self.helper.get_metadata_for_config_id( + section, config_name + ) + options = list(node.value.keys()) + sect_map.update( + { + section: metomi.rose.section.Section( + section, options, meta_data + ) + } + ) + sect_map[section].comments = list(node.comments) + real_sect_ids.append(section) + real_sect_basic_ids.extend( + [ + metomi.rose.macro.REC_ID_STRIP_DUPL.sub("", section), + metomi.rose.macro.REC_ID_STRIP.sub("", section), + ] + ) + if node.is_ignored(): + reason = {} + if ( + node.state + == metomi.rose.config.ConfigNode.STATE_SYST_IGNORED + ): + reason = { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + elif ( + node.state + == metomi.rose.config.ConfigNode.STATE_USER_IGNORED + ): + reason = { + metomi.rose.variable.IGNORED_BY_USER: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + sect_map[section].ignored_reason.update(reason) + if "" not in sect_map: + # This always exists for a configuration. + meta_data = self.helper.get_metadata_for_config_id("", config_name) + sect_map.update( + {"": metomi.rose.section.Section("", [], meta_data)} + ) + real_sect_ids.append("") + for setting_id, sect_node in list(meta_config.value.items()): + if sect_node.is_ignored() or isinstance(sect_node.value, str): + continue + section, option = self.util.get_section_option_from_id(setting_id) + if ( + option is not None + or section in real_sect_ids + or section in real_sect_basic_ids + ): + continue + meta_data = {} + for prop_opt, opt_node in list(sect_node.value.items()): + if opt_node.is_ignored(): + continue + meta_data.update({prop_opt: opt_node.value}) + latent_section_name = section + if ( + meta_data.get(metomi.rose.META_PROP_DUPLICATE) + == metomi.rose.META_PROP_VALUE_TRUE + ): + latent_section_name = section + "({0})".format( + metomi.rose.CONFIG_SETTING_INDEX_DEFAULT + ) + meta_data.update({"id": latent_section_name}) + if section not in ["ns", "file:*"]: + latent_sect_map[latent_section_name] = ( + metomi.rose.section.Section( + latent_section_name, [], meta_data + ) + ) + return sect_map, latent_sect_map + + def load_vars_from_config( + self, + config_name, + only_this_section=None, + save=False, + update=False, + return_copies=False, + ): + """Return maps of variables from the configuration""" + config_data = self.config[config_name] + if save: + config = config_data.save_config + section_map = config_data.sections.save + latent_section_map = config_data.sections.latent_save + else: + config = config_data.config + section_map = config_data.sections.now + latent_section_map = config_data.sections.latent + meta_config = config_data.meta + if update: + if save: + var_map = config_data.vars.save + latent_var_map = config_data.vars.latent_save + else: + var_map = config_data.vars.now + latent_var_map = config_data.vars.latent + else: + var_map = {} + latent_var_map = {} + if return_copies: + var_map_copy = {} + latent_var_map_copy = {} + real_var_ids = [] + basic_dupl_map = {} + if only_this_section is None: + key_nodes = config.walk() + else: + key_nodes = config.walk(keys=[only_this_section]) + self._load_dupl_sect_map(basic_dupl_map, only_this_section) + for keylist, node in key_nodes: + if len(keylist) < 2: + self._load_dupl_sect_map(basic_dupl_map, keylist[0]) + continue + section, option = keylist + flags = self.load_option_flags(config_name, section, option) + ignored_reason = {} + if section_map[section].ignored_reason: + ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_SECTION: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + ) + if node.state == metomi.rose.config.ConfigNode.STATE_SYST_IGNORED: + ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + ) + elif ( + node.state == metomi.rose.config.ConfigNode.STATE_USER_IGNORED + ): + ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_USER: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + ) + cfg_comments = node.comments + var_id = self.util.get_id_from_section_option(section, option) + real_var_ids.append(var_id) + meta_data = self.helper.get_metadata_for_config_id( + var_id, config_name + ) + var_map.setdefault(section, []) + if return_copies: + var_map_copy.setdefault(section, []) + if update: + id_list = [v.metadata["id"] for v in var_map[section]] + if var_id in id_list: + for i, var in enumerate(var_map[section]): + if var.metadata["id"] == var_id: + var_map[section].pop(i) + break + var_map[section].append( + metomi.rose.variable.Variable( + option, + node.value, + meta_data, + ignored_reason, + error={}, + flags=flags, + comments=cfg_comments, + ) + ) + if return_copies: + var_map_copy[section].append( + metomi.rose.variable.Variable( + option, + node.value, + meta_data, + ignored_reason, + error={}, + flags=flags, + comments=cfg_comments, + ) + ) + id_node_stack = list(meta_config.value.items()) + while id_node_stack: + setting_id, sect_node = id_node_stack.pop(0) + if sect_node.is_ignored() or isinstance(sect_node.value, str): + continue + section, option = self.util.get_section_option_from_id(setting_id) + if section in ["ns", "file:*"]: + continue + if section in basic_dupl_map: + # There is a matching duplicate e.g. foo(3) or foo{bar}(1) + for dupl_section in basic_dupl_map[section]: + dupl_id = self.util.get_id_from_section_option( + dupl_section, option + ) + id_node_stack.insert(0, (dupl_id, sect_node)) + continue + if only_this_section is not None and section != only_this_section: + continue + if option is None: + # A section, not a variable. + continue + if setting_id in real_var_ids: + # This variable isn't missing, so skip. + continue + if ( + meta_config.get_value( + [section, metomi.rose.META_PROP_DUPLICATE] + ) + == metomi.rose.META_PROP_VALUE_TRUE + and section not in basic_dupl_map + and config.get([section]) is None + ): + section = section + "({0})".format( + metomi.rose.CONFIG_SETTING_INDEX_DEFAULT + ) + setting_id = self.util.get_id_from_section_option( + section, option + ) + flags = self.load_option_flags(config_name, section, option) + ignored_reason = {} + sect_data = section_map.get(section) + if sect_data is None: + sect_data = latent_section_map.get(section) + if sect_data is not None and sect_data.ignored_reason: + ignored_reason = { + metomi.rose.variable.IGNORED_BY_SECTION: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG + ) + } + meta_data = {} + for prop_opt, opt_node in list(sect_node.value.items()): + if opt_node.is_ignored(): + continue + meta_data.update({prop_opt: opt_node.value}) + meta_data.update({"id": setting_id}) + value = metomi.rose.variable.get_value_from_metadata(meta_data) + latent_var_map.setdefault(section, []) + if return_copies: + latent_var_map_copy.setdefault(section, []) + if update: + id_list = [v.metadata["id"] for v in latent_var_map[section]] + if setting_id in id_list: + for var in latent_var_map[section]: + if var.metadata["id"] == setting_id: + latent_var_map[section].remove(var) + latent_var_map[section].append( + metomi.rose.variable.Variable( + option, + value, + meta_data, + ignored_reason, + error={}, + flags=flags, + ) + ) + if return_copies: + latent_var_map_copy[section].append( + metomi.rose.variable.Variable( + option, + value, + meta_data, + ignored_reason, + error={}, + flags=flags, + ) + ) + if return_copies: + return var_map, latent_var_map, var_map_copy, latent_var_map_copy + return var_map, latent_var_map + + def _load_dupl_sect_map(self, basic_dupl_map, section): + basic_section = metomi.rose.macro.REC_ID_STRIP.sub("", section) + if basic_section != section: + basic_dupl_map.setdefault(basic_section, []) + basic_dupl_map[basic_section].append(section) + mod_section = metomi.rose.macro.REC_ID_STRIP_DUPL.sub("", section) + if mod_section != basic_section and mod_section != section: + basic_dupl_map.setdefault(mod_section, []) + basic_dupl_map[mod_section].append(section) + + def load_option_flags(self, config_name, section, option): + """Load flags for an option.""" + flags = {} + opt_conf_flags = self._load_opt_conf_flags( + config_name, section, option + ) + if opt_conf_flags: + flags.update( + {metomi.rose.config_editor.FLAG_TYPE_OPT_CONF: opt_conf_flags} + ) + return flags + + def _load_opt_conf_flags(self, config_name, section, option): + opt_config_map = self.config[config_name].opt_configs + opt_conf_diff_format = ( + metomi.rose.config_editor.VAR_FLAG_TIP_OPT_CONF_STATE + ) + opt_flags = {} + for opt_name in sorted(opt_config_map): + opt_config = opt_config_map[opt_name] + opt_node = opt_config.get([section, option]) + if opt_node is not None: + opt_sect_node = opt_config.get([section]) + text = opt_conf_diff_format.format( + opt_sect_node.state, + section, + opt_node.state, + option, + opt_node.value, + ) + opt_flags[opt_name] = text + return opt_flags + + def add_section_to_config(self, section, config_name): + """Add a blank section to the configuration.""" + self.config[config_name].config.set([section]) + + def dump_to_internal_config(self, config_name, only_this_ns=None): + """Return a metomi.rose.config.ConfigNode object from variable info.""" + config = metomi.rose.config.ConfigNode() + var_map = self.config[config_name].vars.now + sect_map = self.config[config_name].sections.now + user_ignored_state = metomi.rose.config.ConfigNode.STATE_USER_IGNORED + syst_ignored_state = metomi.rose.config.ConfigNode.STATE_SYST_IGNORED + enabled_state = metomi.rose.config.ConfigNode.STATE_NORMAL + sections_to_be_dumped = [] + if only_this_ns is None: + allowed_sections = set( + list(sect_map.keys()) + list(var_map.keys()) + ) + else: + allowed_sections = self.helper.get_sections_from_namespace( + only_this_ns + ) + for section in sect_map: + if only_this_ns is not None and section not in allowed_sections: + continue + sections_to_be_dumped.append(section) + for section in allowed_sections: + variables = var_map.get(section, []) + for variable in variables: + if only_this_ns is not None: + if variable.metadata["full_ns"] != only_this_ns: + continue + option = variable.name + if not variable.name: + var_id = variable.metadata["id"] + option = self.util.get_section_option_from_id(var_id)[1] + value = variable.value + var_state = enabled_state + if variable.ignored_reason: + if ( + metomi.rose.variable.IGNORED_BY_USER + in variable.ignored_reason + ): + var_state = user_ignored_state + elif ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in variable.ignored_reason + ): + var_state = syst_ignored_state + var_comments = variable.comments + config.set( + [section, option], + value, + state=var_state, + comments=var_comments, + ) + for section_id in sections_to_be_dumped: + comments = sect_map[section_id].comments + if not section_id: + config.comments = list(comments) + continue + section_state = enabled_state + if sect_map[section_id].ignored_reason: + if ( + metomi.rose.variable.IGNORED_BY_USER + in sect_map[section_id].ignored_reason + ): + section_state = user_ignored_state + elif ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in sect_map[section_id].ignored_reason + ): + section_state = syst_ignored_state + node = config.get([section_id]) + if node is None: + config.set([section_id], state=section_state) + node = config.get([section_id]) + else: + node.state = section_state + node.comments = list(comments) + return config + + def load_meta_path(self, config=None, directory=None): + """Retrieve the path to the metadata.""" + return metomi.rose.macro.load_meta_path(config, directory) + + def clear_meta_lookups(self, config_name): + for ns in list(self.namespace_meta_lookup.keys()): + if ( + ns.startswith(config_name) + and self.util.split_full_ns(self, ns)[0] == config_name + ): + self.namespace_meta_lookup.pop(ns) + if config_name in self._config_section_namespace_map: + self._config_section_namespace_map.pop(config_name) + + def load_meta_config_tree( + self, + config=None, + directory=None, + config_type=None, + opt_meta_paths=None, + ): + """Load the main metadata, and any specified in 'config'.""" + if config is None: + config = metomi.rose.config.ConfigNode() + error_handler = metomi.rose.config_editor.util.launch_error_dialog + return metomi.rose.macro.load_meta_config_tree( + config, + directory, + config_type=config_type, + error_handler=error_handler, + opt_meta_paths=opt_meta_paths, + no_warn=self.no_warn, + ) + + def load_meta_files(self, config_tree): + """Load the file paths of files within the metadata directory.""" + meta_files = [] + for rel_path, conf_dir in list(config_tree.files.items()): + meta_files.append(os.path.join(conf_dir, rel_path)) + return meta_files + + def filter_meta_config(self, config_name): + """Filter out invalid metadata.""" + config_data = self.config[config_name] + config = config_data.config + meta_config = config_data.meta + directory = config_data.directory + meta_dir_path = self.load_meta_path(config, directory)[0] + reports = metomi.rose.metadata_check.metadata_check( + meta_config, directory + ) + if reports and meta_dir_path not in self._bad_meta_dir_paths: + # There are problems with some metadata. + title = ( + metomi.rose.config_editor.ERROR_METADATA_CHECKER_TITLE.format( + meta_dir_path + ) + ) + text = ( + metomi.rose.config_editor.ERROR_METADATA_CHECKER_TEXT.format( + len(reports), meta_dir_path + ) + ) + self._bad_meta_dir_paths.append(meta_dir_path) + reports_map = {None: reports} + reports_text = metomi.rose.macro.get_reports_as_text( + reports_map, "metomi.rose.metadata_check.MetadataChecker" + ) + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + text, + title, + modal=False, + extra_text=reports_text, + ) + for report in reports: + if report.option != metomi.rose.META_PROP_TRIGGER: + meta_config.unset([report.section, report.option]) + + def load_ignored_data(self, config_name): + """Deal with ignored variables and sections. + + In particular, this assigns errors based on incorrect ignore + state. + + 'Doc table' in the comments refers to + doc/rose-configuration-metadata.html#appendix-ignored-config-edit + + """ + self.trigger[config_name] = metomi.rose.macros.trigger.TriggerMacro() + config = self.config[config_name].config + sect_map = self.config[config_name].sections.now + latent_sect_map = self.config[config_name].sections.latent + var_map = self.config[config_name].vars.now + latent_var_map = self.config[config_name].vars.latent + config_for_macro = metomi.rose.config.ConfigNode() + enabled_state = metomi.rose.config.ConfigNode.STATE_NORMAL + syst_ignored_state = metomi.rose.config.ConfigNode.STATE_SYST_IGNORED + # Deliberately reset state information in the macro config. + for keylist, node in config.walk(): + if len(keylist) == 1 and list(node.value.keys()): + # Setting non-empty section info would overwrite options. + continue + config_for_macro.set(keylist, copy.deepcopy(node.value)) + meta_config = self.config[config_name].meta + bad_list = self.trigger[config_name].validate_dependencies( + config_for_macro, meta_config + ) + if bad_list: + self.trigger[config_name].trigger_family_lookup.clear() + event = metomi.rose.config_editor.EVENT_INVALID_TRIGGERS.format( + config_name.strip("/") + ) + self.reporter.report(event, self.reporter.KIND_ERR) + return + trig_config = self.trigger[config_name].transform( + config_for_macro, meta_config + )[0] + self.trigger_id_value_lookup.setdefault(config_name, {}) + var_id_map = {} + for variables in list(var_map.values()): + for variable in variables: + var_id_map.update({variable.metadata["id"]: variable}) + latent_var_id_map = {} + for variables in list(latent_var_map.values()): + for variable in variables: + latent_var_id_map.update({variable.metadata["id"]: variable}) + trig_ids = list(self.trigger[config_name].trigger_family_lookup.keys()) + while trig_ids: + var_id = trig_ids.pop() + var = var_id_map.get(var_id) + if var is None: + value = None + else: + value = var.value + self.trigger_id_value_lookup[config_name].update({var_id: value}) + sect, opt = self.util.get_section_option_from_id(var_id) + if sect.endswith(")"): + continue + node = meta_config.get([sect, metomi.rose.META_PROP_DUPLICATE]) + if ( + node is not None + and node.value == metomi.rose.META_PROP_VALUE_TRUE + ): + search_string = sect + "(" + for section in sect_map: + if section.startswith(search_string): + new_id = self.util.get_id_from_section_option( + section, opt + ) + trig_ids.append(new_id) + id_node_map = {} + id_node_map.update(sect_map) + id_node_map.update(latent_sect_map) + id_node_map.update(var_id_map) + id_node_map.update(latent_var_id_map) + ignored_dict = self.trigger[config_name].ignored_dict + enabled_dict = self.trigger[config_name].enabled_dict + for setting_id, node_inst in list(id_node_map.items()): + is_latent = False + section, option = self.util.get_section_option_from_id(setting_id) + is_section = option is None + if is_section: + if section not in sect_map: + is_latent = True + else: + if setting_id not in var_id_map: + is_latent = True + trig_cfg_node = trig_config.get([section, option]) + if trig_cfg_node is None: + # Latent variable or sections cannot be user-ignored. + if ( + setting_id in ignored_dict + and setting_id not in enabled_dict + ): + trig_cfg_state = syst_ignored_state + else: + trig_cfg_state = enabled_state + else: + trig_cfg_state = trig_cfg_node.state + if ( + trig_cfg_state == enabled_state + and not node_inst.ignored_reason + ): + # For speed, skip the rest of the checking. + # Doc table: E -> E + continue + comp_val = node_inst.metadata.get(metomi.rose.META_PROP_COMPULSORY) + node_is_compulsory = comp_val == metomi.rose.META_PROP_VALUE_TRUE + ignored_reasons = list(node_inst.ignored_reason.keys()) + if trig_cfg_state == syst_ignored_state: + # It should be trigger-ignored. + # Doc table: * -> I_t + info = ignored_dict.get(setting_id) + if ( + metomi.rose.variable.IGNORED_BY_SYSTEM + not in ignored_reasons + ): + help_str = ", ".join(list(info.values())) + if metomi.rose.variable.IGNORED_BY_USER in ignored_reasons: + # It is user-ignored but should be trigger-ignored. + # Doc table: I_u -> I_t + if node_is_compulsory: + # Doc table: I_u -> I_t -> compulsory + key = ( + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED # noqa: E501 + ) + val = getattr( + metomi.rose.config_editor, + "WARNING_USER_NOT_TRIGGER_IGNORED", + ) + node_inst.warning.update({key: val}) + else: + # Doc table: I_u -> I_t -> optional + pass + else: + # It is not ignored at all. + # Doc table: E -> I_t + if is_latent: + # Fix this for latent settings. + node_inst.ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_CONFIG # noqa: E501 + ) + } + ) + else: + # Flag an error for real settings. + node_inst.error.update( + { + metomi.rose.config_editor.WARNING_TYPE_ENABLED: ( # noqa: E501 + metomi.rose.config_editor.WARNING_NOT_IGNORED # noqa: E501 + + help_str + ) + } + ) + else: + # Otherwise, they both agree about trigger-ignored. + # Doc table: I_t -> I_t + pass + elif metomi.rose.variable.IGNORED_BY_SYSTEM in ignored_reasons: + # It should be enabled, but is trigger-ignored. + # Doc table: I_t + if ( + setting_id in enabled_dict + and setting_id not in ignored_dict + ): + # It is a valid trigger. + # Doc table: I_t -> E + parents = self.trigger[config_name].enabled_dict.get( + setting_id + ) + help_str = ( + metomi.rose.config_editor.WARNING_NOT_ENABLED + + ", ".join(parents) + ) + err_type = ( + metomi.rose.config_editor.WARNING_TYPE_TRIGGER_IGNORED + ) + node_inst.error.update({err_type: help_str}) + elif ( + setting_id not in enabled_dict + and setting_id not in ignored_dict + ): + # It is not a valid trigger. + # Doc table: I_t -> not trigger + if node_is_compulsory: + # This is an error for compulsory variables. + # Doc table: I_t -> not trigger -> compulsory + help_str = ( + metomi.rose.config_editor.WARNING_NOT_TRIGGER + ) + err_type = ( + metomi.rose.config_editor.WARNING_TYPE_NOT_TRIGGER + ) + node_inst.error.update({err_type: help_str}) + else: + # Overlook for optional variables. + # Doc table: I_t -> not trigger -> optional + pass + elif metomi.rose.variable.IGNORED_BY_USER in ignored_reasons: + # It possibly should be enabled, but is user-ignored. + # Doc table: I_u + # We've already covered I_u -> I_t + if node_is_compulsory: + # Compulsory settings should not be user-ignored. + # Doc table: I_u -> E -> compulsory + # Doc table: I_u -> not trigger -> compulsory + help_str = ( + metomi.rose.config_editor.WARNING_NOT_USER_IGNORABLE + ) + err_type = ( + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED + ) + node_inst.error.update({err_type: help_str}) + # Remaining possibilities are not a problem: + # Doc table: E -> E, E -> not trigger + + def load_file_metadata(self, config_name, section_name=None): + """Deal with file section variables.""" + if section_name is not None and not section_name.startswith("file:"): + return False + config = self.config[config_name].config + meta_config = self.config[config_name].meta + file_sections = [] + for section, sect_node in list(config.value.items()): + if not isinstance(sect_node.value, dict): + continue + if not sect_node.is_ignored() and section.startswith("file:"): + file_sections.append(section) + duplicate_file_sections = [] + for meta_id, sect_node in list(meta_config.value.items()): + section, option = self.util.get_section_option_from_id(meta_id) + if option is None: + if not isinstance(sect_node.value, dict): + continue + if not sect_node.is_ignored() and section.startswith("file:"): + file_sections.append(section) + if ( + sect_node.get_value([metomi.rose.META_PROP_DUPLICATE]) + == metomi.rose.META_PROP_VALUE_TRUE + ): + duplicate_file_sections.append(section) + # Remove metadata for individual duplicate sections - no need. + for section in list(file_sections): + if section in duplicate_file_sections: + continue + base_section = metomi.rose.macro.REC_ID_STRIP.sub("", section) + if base_section in duplicate_file_sections: + file_sections.remove(section) + file_ids = [] + for setting_id, sect_node in list(meta_config.value.items()): + # The following 'wildcard-esque' id is an exception. + # Wildcards are not supported in Rose metadata. + if not sect_node.is_ignored() and setting_id.startswith("file:*="): + file_ids.append(setting_id) + for section in file_sections: + title = meta_config.get_value( + [section, metomi.rose.META_PROP_TITLE] + ) + if title is None: + meta_config.set( + [section, metomi.rose.META_PROP_TITLE], + section.replace("file:", "", 1), + ) + for file_entry in file_ids: + sect_node = meta_config.get([file_entry]) + for meta_prop, opt_node in list(sect_node.value.items()): + if opt_node.is_ignored(): + continue + prop_val = opt_node.value + new_id = ( + section + "=" + file_entry.replace("file:*=", "", 1) + ) + if meta_config.get([new_id, meta_prop]) is None: + meta_config.set([new_id, meta_prop], prop_val) + + def load_node_namespaces( + self, config_name, only_this_section=None, from_saved=False + ): + """Load namespaces for variables and sections.""" + config_sections = self.config[config_name].sections + config_vars = self.config[config_name].vars + for section, variables in config_vars.foreach(from_saved): + if only_this_section is not None and section != only_this_section: + continue + for variable in variables: + self.load_ns_for_node(variable, config_name) + section_objects = [] + if only_this_section is not None: + if only_this_section in config_sections.now: + section_objects = [config_sections.now[only_this_section]] + elif only_this_section in config_sections.latent: + section_objects = [config_sections.latent[only_this_section]] + else: + section_objects = config_sections.get_all(save=from_saved) + for sect_obj in section_objects: + self.load_ns_for_node(sect_obj, config_name) + + def load_ns_for_node(self, node, config_name): + """Load a namespace for a variable or section.""" + node_id = node.metadata.get("id") + section, option = self.util.get_section_option_from_id(node_id) + subspace = node.metadata.get(metomi.rose.META_PROP_NS) + if subspace is None or option is None: + new_namespace = self.helper.get_default_section_namespace( + section, config_name + ) + else: + new_namespace = config_name + "/" + subspace + if new_namespace == config_name + "/": + new_namespace = config_name + node.metadata["full_ns"] = new_namespace + return new_namespace + + def load_metadata_for_namespaces(self, config_name): + """Load namespace metadata, e.g. namespace titles.""" + config_data = self.config[config_name] + meta_config = config_data.meta + for setting_id, sect_node in list(meta_config.value.items()): + if sect_node.is_ignored(): + continue + section, option = self.util.get_section_option_from_id(setting_id) + is_ns = section == "ns" + is_duplicate_section = ( + self.util.get_section_option_from_id(section)[1] is None + and sect_node.get_value([metomi.rose.META_PROP_DUPLICATE]) + == metomi.rose.META_PROP_VALUE_TRUE + ) + if is_ns or is_duplicate_section: + if is_ns: + subspace = option + if subspace: + namespace = config_name + "/" + subspace + else: + namespace = config_name + else: + subspace = sect_node.get_value([metomi.rose.META_PROP_NS]) + if subspace is None: + namespace = self.helper.get_default_section_namespace( + section, config_name + ) + else: + if subspace: + namespace = config_name + "/" + subspace + else: + namespace = config_name + self.namespace_meta_lookup.setdefault(namespace, {}) + ns_metadata = self.namespace_meta_lookup[namespace] + for option, opt_node in list(sect_node.value.items()): + if opt_node.is_ignored(): + continue + value = meta_config[setting_id][option].value + if option == metomi.rose.META_PROP_MACRO: + if option in ns_metadata: + ns_metadata[option] += ", " + value + else: + ns_metadata[option] = value + else: + ns_metadata.update({option: value}) + ns_sections = {} # Namespace-sections key value pairs. + for variable in config_data.vars.get_all(): + ns = variable.metadata["full_ns"] + var_id = variable.metadata["id"] + sect = self.util.get_section_option_from_id(var_id)[0] + ns_sections.setdefault(ns, []) + if sect not in ns_sections[ns]: + ns_sections[ns].append(sect) + if metomi.rose.META_PROP_MACRO in variable.metadata: + macro_info = variable.metadata[metomi.rose.META_PROP_MACRO] + self.namespace_meta_lookup.setdefault(ns, {}) + ns_metadata = self.namespace_meta_lookup[ns] + if metomi.rose.META_PROP_MACRO in ns_metadata: + ns_metadata[metomi.rose.META_PROP_MACRO] += ( + ", " + macro_info + ) + else: + ns_metadata[metomi.rose.META_PROP_MACRO] = macro_info + default_ns_sections = {} + for section_data in config_data.sections.get_all(): + # Use the default section namespace. + ns = section_data.metadata["full_ns"] + ns_sections.setdefault(ns, []) + if section_data.name not in ns_sections[ns]: + ns_sections[ns].append(section_data.name) + default_ns_sections.setdefault(ns, []) + if section_data.name not in default_ns_sections[ns]: + default_ns_sections[ns].append(section_data.name) + for ns in ns_sections: + self.namespace_meta_lookup.setdefault(ns, {}) + ns_metadata = self.namespace_meta_lookup[ns] + ns_metadata["sections"] = ns_sections[ns] + for ns_section in ns_sections[ns]: + # Loop over metadata from contributing sections. + # Note: rogue-variable section metadata can be overridden. + metadata = self.helper.get_metadata_for_config_id( + ns_section, config_name + ) + for key, value in list(metadata.items()): + if ns_section not in default_ns_sections.get( + ns, [] + ) and key in [ + metomi.rose.META_PROP_TITLE, + metomi.rose.META_PROP_SORT_KEY, + metomi.rose.META_PROP_DESCRIPTION, + ]: + # ns created from variables, not a section - no title. + continue + if key == metomi.rose.META_PROP_MACRO: + macro_info = value + if key in ns_metadata: + ns_metadata[metomi.rose.META_PROP_MACRO] += ( + ", " + macro_info + ) + else: + ns_metadata[metomi.rose.META_PROP_MACRO] = ( + macro_info + ) + else: + ns_metadata.setdefault(key, value) + self.load_namespace_has_sub_data(config_name) + for config_name in list(self.config.keys()): + icon_path = self.helper.get_icon_path_for_config(config_name) + self.namespace_meta_lookup.setdefault(config_name, {}) + self.namespace_meta_lookup[config_name].setdefault( + "icon", icon_path + ) + if ( + self.config[config_name].config_type + == metomi.rose.TOP_CONFIG_NAME + ): + self.namespace_meta_lookup[config_name].setdefault( + metomi.rose.META_PROP_TITLE, + metomi.rose.config_editor.TITLE_PAGE_SUITE, + ) + self.namespace_meta_lookup[config_name].setdefault( + metomi.rose.META_PROP_SORT_KEY, " 1" + ) + elif ( + self.config[config_name].config_type + == metomi.rose.INFO_CONFIG_NAME + ): + self.namespace_meta_lookup[config_name].setdefault( + metomi.rose.META_PROP_TITLE, + metomi.rose.config_editor.TITLE_PAGE_INFO, + ) + self.namespace_meta_lookup[config_name].setdefault( + metomi.rose.META_PROP_SORT_KEY, " 0" + ) + + def load_namespace_has_sub_data(self, config_name=None): + """Load namespace sub-data status.""" + file_ns = "/" + metomi.rose.SUB_CONFIG_FILE_DIR + ns_hierarchy = {} + for ns in self.namespace_meta_lookup: + if config_name is None or ns.startswith(config_name): + parent_ns = ns.rsplit("/", 1)[0] + ns_hierarchy.setdefault(parent_ns, []) + ns_hierarchy[parent_ns].append(ns) + if config_name is None: + configs = list(self.config.keys()) + else: + configs = [config_name] + # File root pages have summary data for files. + for alt_config_name in configs: + file_root_ns = alt_config_name + file_ns + self.namespace_meta_lookup.setdefault(file_root_ns, {}) + self.namespace_meta_lookup[file_root_ns].setdefault( + "has_sub_data", True + ) + # Duplicate root pages have summary data for their members. + for ns, prop_map in list(self.namespace_meta_lookup.items()): + if config_name is not None and not ns.startswith(config_name): + continue + if ( + metomi.rose.META_PROP_DUPLICATE in prop_map + and ns_hierarchy.get(ns, []) + ): + prop_map.setdefault("has_sub_data", True) diff --git a/metomi/rose/config_editor/data_helper.py b/metomi/rose/config_editor/data_helper.py new file mode 100644 index 0000000000..a1e39cc33c --- /dev/null +++ b/metomi/rose/config_editor/data_helper.py @@ -0,0 +1,522 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re +from functools import cmp_to_key + +import metomi.rose.config + + +REC_ELEMENT_SECTION = re.compile(r"^(.*)\((.+)\)$") + + +class ConfigDataHelper(object): + + def __init__(self, data, util): + self.data = data + self.util = util + + def get_config_has_unsaved_changes(self, config_name): + """Return True if there are unsaved changes for config_name.""" + config_data = self.data.config[config_name] + variables = config_data.vars.get_all(skip_latent=True) + save_vars = config_data.vars.get_all(save=True, skip_latent=True) + sections = config_data.sections.get_all(skip_latent=True) + save_sections = config_data.sections.get_all( + save=True, skip_latent=True + ) + now_set = set([v.to_hashable() for v in variables]) + save_set = set([v.to_hashable() for v in save_vars]) + now_sect_set = set([s.to_hashable() for s in sections]) + save_sect_set = set([s.to_hashable() for s in save_sections]) + return ( + config_name not in self.data.saved_config_names + or now_set ^ save_set + or now_sect_set ^ save_sect_set + ) + + def get_config_meta_flag(self, config_name, from_this_config_obj=None): + """Return the metadata id flag.""" + for section, option in [ + [metomi.rose.CONFIG_SECT_TOP, metomi.rose.CONFIG_OPT_META_TYPE], + [metomi.rose.CONFIG_SECT_TOP, metomi.rose.CONFIG_OPT_PROJECT], + ]: + if from_this_config_obj is not None: + type_node = from_this_config_obj.get( + [section, option], no_ignore=True + ) + if type_node is not None and type_node.value: + return type_node.value + continue + id_ = self.util.get_id_from_section_option(section, option) + var = self.get_variable_by_id(id_, config_name) + if var is not None: + return var.value + return None + + def is_ns_sub_data(self, ns): + """Return whether a namespace is mentioned in summary data.""" + ns_meta = self.data.namespace_meta_lookup.get(ns, {}) + return ns_meta.get("has_sub_data", False) + + def is_ns_content(self, ns): + """Return whether a namespace has any existing content.""" + config_name = self.util.split_full_ns(self.data, ns)[0] + for section in self.get_sections_from_namespace(ns): + if section in self.data.config[config_name].sections.now: + return True + return self.is_ns_sub_data(ns) + + def get_metadata_for_config_id(self, node_id, config_name): + """Retrieve the corresponding metadata for a variable.""" + config_data = self.data.config[config_name] + meta_config = config_data.meta + if not node_id: + return {"id": node_id} + return metomi.rose.macro.get_metadata_for_config_id( + node_id, meta_config + ) + + def get_variable_by_id( + self, var_id, config_name, save=False, latent=False + ): + """Return the matching variable or None.""" + sect, opt = self.util.get_section_option_from_id(var_id) + return self.data.config[config_name].vars.get_var( + sect, opt, save, skip_latent=not latent + ) + + # ----------------- Data model helper functions --------------------------- + + def get_data_for_namespace(self, ns, from_saved=False): + """Return a list of vars and a list of latent vars for this ns.""" + config_name = self.util.split_full_ns(self.data, ns)[0] + config_data = self.data.config[config_name] + allowed_sections = self.get_sections_from_namespace(ns) + variables = [] + latents = [] + if from_saved: + var_map = config_data.vars.save + latent_var_map = config_data.vars.latent_save + else: + var_map = config_data.vars.now + latent_var_map = config_data.vars.latent + for section in allowed_sections: + variables.extend(var_map.get(section, [])) + latents.extend(latent_var_map.get(section, [])) + ns_vars = [v for v in variables if v.metadata.get("full_ns") == ns] + ns_latents = [v for v in latents if v.metadata.get("full_ns") == ns] + return ns_vars, ns_latents + + def get_macro_info_for_namespace(self, ns): + """Return some information for custom macros for this namespace.""" + config_name = self.util.split_full_ns(self, ns)[0] + config_data = self.data.config[config_name] + ns_macros_text = self.data.namespace_meta_lookup.get(ns, {}).get( + metomi.rose.META_PROP_MACRO, "" + ) + if not ns_macros_text: + return {} + ns_macros = metomi.rose.variable.array_split( + ns_macros_text, only_this_delim="," + ) + module_prefix = self.get_macro_module_prefix(config_name) + for i, ns_macro in enumerate(ns_macros): + ns_macros[i] = module_prefix + ns_macro + ns_macro_info = {} + macro_tuples = metomi.rose.macro.get_macro_class_methods( + config_data.macros + ) + for module_name, class_name, method_name, docstring in macro_tuples: + this_macro_name = ".".join([module_name, class_name]) + this_macro_method_name = ".".join([this_macro_name, method_name]) + this_info = (method_name, docstring) + if this_macro_name in ns_macros: + key = this_macro_name.replace(module_prefix, "", 1) + ns_macro_info.update({key: this_info}) + elif this_macro_method_name in ns_macros: + key = this_macro_method_name.replace(module_prefix, "", 1) + ns_macro_info.update({key: this_info}) + return ns_macro_info + + def get_section_data_for_namespace(self, ns): + """Return real and latent lists of Section objects for this ns.""" + allowed_sections = self.data.helper.get_sections_from_namespace(ns) + config_name = self.util.split_full_ns(self.data, ns)[0] + config_data = self.data.config[config_name] + real_sections = [] + for section, sect_data in list(config_data.sections.now.items()): + if section in allowed_sections: + real_sections.append(sect_data) + latent_sections = [] + for section, sect_data in list(config_data.sections.latent.items()): + if section in allowed_sections: + latent_sections.append(sect_data) + return real_sections, latent_sections + + def get_sub_data_for_namespace(self, ns, from_saved=False): + """Return any sections/variables below this namespace.""" + sub_data = {"sections": {}, "variables": {}} + config_name = self.util.split_full_ns(self.data, ns)[0] + config_data = self.data.config[config_name] + for sect, sect_data in list(config_data.sections.now.items()): + sect_ns = sect_data.metadata["full_ns"] + if sect_ns.startswith(ns): + sub_data["sections"].update({sect: sect_data}) + for sect, variables in list(config_data.vars.now.items()): + for variable in variables: + if variable.metadata["full_ns"].startswith(ns): + sub_data["variables"].setdefault(sect, []) + sub_data["variables"][sect].append(variable) + if not sub_data["sections"] and not sub_data["variables"]: + return None + return sub_data + + def get_sub_data_var_id_value_map(self, config_name): + """Return all real (=existing) variable values for sub data.""" + config_data = self.data.config[config_name] + var_id_val_map = {} + for variable in config_data.vars.get_all(): + var_id_val_map.update({variable.metadata["id"]: variable.value}) + return var_id_val_map + + def get_ns_comment_string(self, ns): + """Return a comment string for this namespace.""" + comment = "" + comments = [] + config_name = self.util.split_full_ns(self.data, ns)[0] + config_data = self.data.config[config_name] + sections = self.get_sections_from_namespace(ns) + sections.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + for section in sections: + sect_data = config_data.sections.now.get(section) + if sect_data is not None and sect_data.comments: + comments.extend(sect_data.comments) + if comments: + comment = "#" + "\n#".join(comments) + return comment + + def get_ns_variable(self, var_id, ns): + """Return a variable with this id in the config specified by ns.""" + config_name = self.util.split_full_ns(self.data, ns)[0] + config_data = self.data.config[config_name] + sect, opt = self.util.get_section_option_from_id(var_id) + var = config_data.vars.get_var(sect, opt) + if var is None: + var = config_data.vars.get_var(sect, opt, save=True) + return var # May be None. + + def get_ns_url_for_variable(self, variable): + """Return the parent (ns or section) URL property, if any.""" + config_name = self.util.split_full_ns( + self.data, variable.metadata["full_ns"] + )[0] + ns_metadata = self.data.namespace_meta_lookup.get( + variable.metadata["full_ns"], {} + ) + ns_url = ns_metadata.get(metomi.rose.META_PROP_URL) + if ns_url: + return ns_url + section = self.util.get_section_option_from_id( + variable.metadata["id"] + )[0] + section_object = self.data.config[config_name].sections.get_sect( + section + ) + section_url = section_object.metadata.get(metomi.rose.META_PROP_URL) + return section_url + + def get_sections_from_namespace(self, namespace): + """Return all sections contributing to a namespace.""" + # FIXME: What about files? + ns_metadata = self.data.namespace_meta_lookup.get(namespace, {}) + sections = ns_metadata.get("sections", []) + if sections: + return [s for s in sections] + base, subsp = self.util.split_full_ns(self.data, namespace) + ns_section = subsp.replace("/", ":") + if ns_section in self.data.config[base].sections.now: + sect_data = self.data.config[base].sections.now[ns_section] + if sect_data.metadata["full_ns"] == namespace: + return [ns_section] + if ns_section in self.data.config[base].sections.latent: + sect_data = self.data.config[base].sections.latent[ns_section] + if sect_data.metadata["full_ns"] == namespace: + return [ns_section] + return [] + + def get_ns_is_default(self, namespace): + """Sets if this namespace is the default for a section. Slow!""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + allowed_sections = self.get_sections_from_namespace(namespace) + empty = True + for section in allowed_sections: + for variable in config_data.vars.now.get(section, []): + if variable.metadata["full_ns"] == namespace: + empty = False + if metomi.rose.META_PROP_NS not in variable.metadata: + return True + for variable in config_data.vars.latent.get(section, []): + if variable.metadata["full_ns"] == namespace: + empty = False + if metomi.rose.META_PROP_NS not in variable.metadata: + return True + if empty: + # An added, non-metadata section with no variables. + return True + return False + + def get_all_namespaces(self, only_this_config=None): + """Return all unique namespaces.""" + nses = list(self.data.namespace_meta_lookup.keys()) + if only_this_config is not None: + nses = [n for n in nses if n.startswith(only_this_config)] + return nses + + def get_missing_sections(self, config_name=None): + """Return full section ids that are missing.""" + full_sections = [] + if config_name is not None: + config_names = [config_name] + else: + config_names = list(self.data.config.keys()) + for config_name in config_names: + section_store = self.data.config[config_name].sections + miss_sections = [] + real_sections = list(section_store.now.keys()) + for section in list(section_store.latent.keys()): + if section not in real_sections: + miss_sections.append(section) + for section in self.data.config[config_name].vars.latent: + if ( + section not in real_sections + and section not in miss_sections + ): + miss_sections.append(section) + full_sections += [config_name + ":" + s for s in miss_sections] + sorter = metomi.rose.config.sort_settings + full_sections.sort(key=cmp_to_key(sorter)) + return full_sections + + def get_default_section_namespace(self, section, config_name): + """Return the default namespace for the section.""" + if config_name not in self.data._config_section_namespace_map: + self.data._config_section_namespace_map.setdefault(config_name, {}) + section_ns = self.data._config_section_namespace_map[config_name].get( + section + ) + if section_ns is None: + config_data = self.data.config[config_name] + meta_config = config_data.meta + node = meta_config.get( + [section, metomi.rose.META_PROP_NS], no_ignore=True + ) + if node is not None: + subspace = node.value + else: + match = REC_ELEMENT_SECTION.match(section) + if match: + node = meta_config.get( + [match.groups()[0], metomi.rose.META_PROP_NS] + ) + if node is None or node.is_ignored(): + subspace = section.replace("(", "/") + subspace = subspace.replace(")", "") + subspace = subspace.replace(":", "/") + else: + subspace = node.value + "/" + str(match.groups()[1]) + elif section.startswith(metomi.rose.SUB_CONFIG_FILE_DIR + ":"): + subspace = section.rstrip("/").replace("/", ":") + subspace = subspace.replace(":", "/", 1) + else: + subspace = section.rstrip("/").replace(":", "/") + section_ns = config_name + "/" + subspace + if not subspace: + section_ns = config_name + self.data._config_section_namespace_map[config_name].update( + {section: section_ns} + ) + return section_ns + + def get_format_sections(self, config_name): + """Return all format-like sections in the current data.""" + format_keys = [] + for section in self.data.config[config_name].sections.now: + if ( + section not in format_keys + and ":" in section + and not section.startswith("file:") + ): + format_keys.append(section) + format_keys.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + return format_keys + + def get_icon_path_for_config(self, config_name): + """Return the path to the config identifier icon or None.""" + icon_path = None + for filename in self.data.config[config_name].meta_files: + if filename.endswith("/images/icon.png"): + icon_path = filename + break + return icon_path + + def get_macro_module_prefix(self, config_name): + """Return a valid module-like name for macros.""" + return re.sub(r"[^\w]", "_", config_name.strip("/")) + "/" + + def get_ignored_sections(self, namespace, get_enabled=False): + """Return the user-ignored sections for this namespace. + + If namespace is a config_name, return all config ignored + sections. + + Return enabled sections instead if get_enabled is True. + + """ + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + if namespace == config_name: + sections = list(config_data.sections.now.keys()) + else: + sections = self.get_sections_from_namespace(namespace) + return_sections = [] + for section in sections: + sect_data = config_data.sections.get_sect(section) + if get_enabled: + if not sect_data.ignored_reason: + return_sections.append(section) + elif ( + metomi.rose.variable.IGNORED_BY_USER + in sect_data.ignored_reason + ): + return_sections.append(section) + return_sections.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + return return_sections + + def get_latent_sections(self, namespace): + """Return the latent sections for this namespace.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + if namespace == config_name: + sections = list(config_data.sections.now.keys()) + else: + sections = self.get_sections_from_namespace(namespace) + return_sections = [] + for section in sections: + if section not in config_data.sections.now: + return_sections.append(section) + return_sections.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + return return_sections + + def get_ns_ignored_status(self, namespace): + """Return the ignored status for a namespace's data.""" + cache = self.data.namespace_cached_statuses["ignored"] + if namespace in cache: + return cache[namespace] + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + sections = self.get_sections_from_namespace(namespace) + status = metomi.rose.config.ConfigNode.STATE_NORMAL + default_section_statuses = {} + variable_statuses = {} + for section in sections: + sect_data = config_data.sections.get_sect(section) + if sect_data is None: + continue + if sect_data.metadata["full_ns"] == namespace: + if not sect_data.ignored_reason: + cache[namespace] = status + return status + for key in sect_data.ignored_reason: + default_section_statuses.setdefault(key, 0) + default_section_statuses[key] += 1 + real_data, latent_data = self.get_data_for_namespace(namespace) + for var in real_data + latent_data: + if not var.ignored_reason: + cache[namespace] = status + return status + for key in var.ignored_reason: + if key == metomi.rose.variable.IGNORED_BY_SECTION: + # Section ignored statuses need interpreting. + var_id = var.metadata["id"] + section = self.util.get_section_option_from_id(var_id)[0] + sect_data = config_data.sections.get_sect(section) + for key2 in sect_data.ignored_reason: + variable_statuses.setdefault(key2, 0) + variable_statuses[key2] += 1 + else: + variable_statuses.setdefault(key, 0) + variable_statuses[key] += 1 + if not (variable_statuses or sections): + # No data, so no ignored state. + cache[namespace] = status + return status + # Now return the most 'popular' ignored status. + # Choose section statuses if any are default for this namespace. + if default_section_statuses: + object_statuses = default_section_statuses + else: + object_statuses = variable_statuses + status_counts = list(object_statuses.items()) + status_counts.sort(key=lambda x: x[1]) + if not status_counts: + cache[namespace] = status + return metomi.rose.config.ConfigNode.STATE_NORMAL + status = status_counts[0][0] + cache[namespace] = status + if status == metomi.rose.variable.IGNORED_BY_USER: + return metomi.rose.config.ConfigNode.STATE_USER_IGNORED + if status == metomi.rose.variable.IGNORED_BY_SYSTEM: + return metomi.rose.config.ConfigNode.STATE_SYST_IGNORED + return metomi.rose.config.ConfigNode.STATE_NORMAL + + def get_ns_latent_status(self, namespace): + """Return whether a page has no associated content.""" + cache = self.data.namespace_cached_statuses["latent"] + if namespace in cache: + return cache[namespace] + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + sections = self.get_sections_from_namespace(namespace) + for section in sections: + if section in config_data.sections.now: + # It has a current section associated. + section_namespace = config_data.sections.now[section].metadata[ + "full_ns" + ] + if section_namespace == namespace: + # This is a default page for an existing section. + cache[namespace] = False + return False + for variable in config_data.vars.now.get(section, []): + if variable.metadata["full_ns"] == namespace: + # This contains an existing variable. + cache[namespace] = False + return False + cache[namespace] = True + return True + + def clear_namespace_cached_statuses(self, namespace): + """Reset cached latent, ignored, modified statuses for namespace.""" + if namespace in self.data.namespace_cached_statuses["ignored"]: + self.data.namespace_cached_statuses["ignored"].pop(namespace) + if namespace in self.data.namespace_cached_statuses["latent"]: + self.data.namespace_cached_statuses["latent"].pop(namespace) diff --git a/metomi/rose/config_editor/keywidget.py b/metomi/rose/config_editor/keywidget.py new file mode 100644 index 0000000000..04c5dc2de2 --- /dev/null +++ b/metomi/rose/config_editor/keywidget.py @@ -0,0 +1,603 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re + +from gi.repository import Pango +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config_editor +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util +import metomi.rose.variable + + +class KeyWidget(Gtk.Box): + """This class generates a label or entry box for a variable name.""" + + FLAG_ICON_MAP = { + metomi.rose.config_editor.FLAG_TYPE_DEFAULT: Gtk.STOCK_INFO, + metomi.rose.config_editor.FLAG_TYPE_ERROR: Gtk.STOCK_DIALOG_WARNING, + metomi.rose.config_editor.FLAG_TYPE_FIXED: ( + Gtk.STOCK_DIALOG_AUTHENTICATION + ), + metomi.rose.config_editor.FLAG_TYPE_OPT_CONF: Gtk.STOCK_INDEX, + metomi.rose.config_editor.FLAG_TYPE_OPTIONAL: Gtk.STOCK_ABOUT, + metomi.rose.config_editor.FLAG_TYPE_NO_META: "dialog-question", + } + + MODIFIED_COLOUR = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_CHANGED + ) + + LABEL_X_OFFSET = 0.01 + + def __init__( + self, variable, var_ops, launch_help_func, update_func, show_modes + ): + super(KeyWidget, self).__init__( + homogeneous=False, spacing=0, orientation=Gtk.Orientation.VERTICAL + ) + self.my_variable = variable + self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.hbox.show() + self.pack_start(self.hbox, expand=False, fill=False, padding=0) + self.var_ops = var_ops + self.meta = variable.metadata + self.launch_help = launch_help_func + self.update_status = update_func + self.show_modes = show_modes + self.var_flags = [] + self._last_var_comments = None + self.ignored_label = Gtk.Label() + self.ignored_label.show() + self.hbox.pack_start( + self.ignored_label, expand=False, fill=False, padding=0 + ) + self.set_ignored() + if self.my_variable.name != "": + self.entry = Gtk.Label() + self.entry.set_alignment( + self.LABEL_X_OFFSET, self.entry.get_alignment()[1] + ) + self.entry.set_text(self.my_variable.name) + else: + self.entry = Gtk.Entry() + self.entry.modify_text(Gtk.StateType.NORMAL, self.MODIFIED_COLOUR) + self.entry.connect( + "focus-out-event", lambda w, e: self._setter(w, variable) + ) + event_box = Gtk.EventBox() + event_box.add(self.entry) + event_box.connect( + "enter-notify-event", lambda b, w: self._handle_enter(b) + ) + event_box.connect( + "leave-notify-event", lambda b, w: self._handle_leave(b) + ) + self.hbox.pack_start(event_box, expand=True, fill=True, padding=0) + self.comments_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.hbox.pack_start( + self.comments_box, expand=False, fill=False, padding=0 + ) + self.grab_focus = self.entry.grab_focus + self.set_sensitive(True) + self.set_sensitive = self._set_sensitive + event_box.connect("button-press-event", self.handle_launch_help) + self.update_comment_display() + self.entry.show() + for key, value in list(self.show_modes.items()): + if key not in [ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_DESCRIPTION, + metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP, + metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE, + ]: + self.set_show_mode(key, value) + if ( + metomi.rose.META_PROP_VALUES in self.meta + and len(self.meta[metomi.rose.META_PROP_VALUES]) == 1 + ): + self.add_flag( + metomi.rose.config_editor.FLAG_TYPE_FIXED, + metomi.rose.config_editor.VAR_FLAG_TIP_FIXED, + ) + event_box.show() + self.show() + + def add_flag(self, flag_type, tooltip_text=None): + """Set the display of a flag denoting a property.""" + if flag_type in self.var_flags: + return + self.var_flags.append(flag_type) + stock_id = self.FLAG_ICON_MAP[flag_type] + event_box = Gtk.EventBox() + event_box._flag_type = flag_type + image = Gtk.Image.new_from_stock(stock_id, Gtk.IconSize.MENU) + image.set_tooltip_text(tooltip_text) + image.show() + event_box.add(image) + event_box.show() + event_box.connect("button-press-event", self._toggle_flag_label) + self.hbox.pack_end( + event_box, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + + def get_centre_height(self): + """Return the vertical displacement of the centre of this widget.""" + return self.entry.get_preferred_size().natural_size.height / 2 + + def handle_launch_help(self, widget, event): + """Handle launching help.""" + if event.type == Gdk.EventType.BUTTON_PRESS and event.button != 3: + url_mode = metomi.rose.META_PROP_HELP not in self.meta + self.launch_help(url_mode=url_mode) + + def launch_edit_comments(self, *args): + """Launch an edit comments dialog.""" + text = "\n".join(self.my_variable.comments) + title = metomi.rose.config_editor.DIALOG_TITLE_EDIT_COMMENTS.format( + self.my_variable.metadata["id"] + ) + metomi.rose.gtk.dialog.run_edit_dialog( + text, finish_hook=self._edit_finish_hook, title=title + ) + + def refresh(self, variable=None): + """Reload the contents - however, no need for this at present.""" + self.my_variable = variable + + def remove_flag(self, flag_type): + """Remove the flag from the widget.""" + for widget in self.get_children(): + if ( + isinstance(widget, Gtk.EventBox) + and getattr(widget, "_flag_type", None) == flag_type + ): + self.remove(widget) + if flag_type in self.var_flags: + self.var_flags.remove(flag_type) + return True + + def set_ignored(self): + """Update the ignored display.""" + self.ignored_label.set_markup( + metomi.rose.variable.get_ignored_markup(self.my_variable) + ) + hover_string = "" + if not self.my_variable.ignored_reason: + self.ignored_label.set_tooltip_text(None) + for key, value in sorted(self.my_variable.ignored_reason.items()): + hover_string += key + " " + value + "\n" + self.ignored_label.set_tooltip_text(hover_string.strip()) + + def set_modified(self, is_modified): + """Set the display of modified status in the text.""" + if is_modified: + if isinstance(self.entry, Gtk.Label): + att_list = self.entry.get_attributes() + if att_list is None: + att_list = Pango.AttrList() + att_list.insert( + Pango.attr_foreground_new( + self.MODIFIED_COLOUR.red, + self.MODIFIED_COLOUR.green, + self.MODIFIED_COLOUR.blue, + ) + ) + self.entry.set_attributes(att_list) + else: + if isinstance(self.entry, Gtk.Label): + att_list = self.entry.get_attributes() + if att_list is not None: + att_list = att_list.filter( + lambda a: a.klass.type != Pango.AttrType.FOREGROUND + ) + + if att_list is None: + att_list = Pango.AttrList() + self.entry.set_attributes(att_list) + + def set_show_mode(self, show_mode, should_show_mode): + """Set the display of a mode on or off.""" + if show_mode in [ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_DESCRIPTION, + metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP, + metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE, + ]: + return self._set_show_custom_meta_text(show_mode, should_show_mode) + if show_mode == metomi.rose.config_editor.SHOW_MODE_NO_TITLE: + return self._set_show_title(not should_show_mode) + if show_mode == metomi.rose.config_editor.SHOW_MODE_NO_DESCRIPTION: + return self._set_show_meta_text_mode( + metomi.rose.META_PROP_DESCRIPTION, not should_show_mode + ) + if show_mode == metomi.rose.config_editor.SHOW_MODE_NO_HELP: + return self._set_show_meta_text_mode( + metomi.rose.META_PROP_HELP, not should_show_mode + ) + if show_mode == metomi.rose.config_editor.SHOW_MODE_FLAG_OPTIONAL: + if ( + should_show_mode + and self.meta.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + ): + return self.add_flag( + metomi.rose.config_editor.FLAG_TYPE_OPTIONAL, + metomi.rose.config_editor.VAR_FLAG_TIP_OPTIONAL, + ) + return self.remove_flag( + metomi.rose.config_editor.FLAG_TYPE_OPTIONAL + ) + if show_mode == metomi.rose.config_editor.SHOW_MODE_FLAG_NO_META: + if should_show_mode and len(self.meta) <= 2: + return self.add_flag( + metomi.rose.config_editor.FLAG_TYPE_NO_META, + metomi.rose.config_editor.VAR_FLAG_TIP_NO_META, + ) + return self.remove_flag( + metomi.rose.config_editor.FLAG_TYPE_NO_META + ) + if show_mode == metomi.rose.config_editor.SHOW_MODE_FLAG_OPT_CONF: + if ( + should_show_mode + and metomi.rose.config_editor.FLAG_TYPE_OPT_CONF + in self.my_variable.flags + ): + opts_info = self.my_variable.flags[ + metomi.rose.config_editor.FLAG_TYPE_OPT_CONF + ] + info_text = "" + info_format = ( + metomi.rose.config_editor.VAR_FLAG_TIP_OPT_CONF_INFO + ) + for opt, diff in sorted(opts_info.items()): + info_text += info_format.format(opt, diff) + info_text = info_text.rstrip() + if info_text: + text = ( + metomi.rose.config_editor.VAR_FLAG_TIP_OPT_CONF.format( + info_text + ) + ) + return self.add_flag( + metomi.rose.config_editor.FLAG_TYPE_OPT_CONF, text + ) + return self.remove_flag( + metomi.rose.config_editor.FLAG_TYPE_OPT_CONF + ) + + def update_comment_display(self): + """Update the display of variable comments.""" + if self.my_variable.comments == self._last_var_comments: + return + self._last_var_comments = self.my_variable.comments + if ( + self.my_variable.comments + or metomi.rose.config_editor.SHOULD_SHOW_ALL_COMMENTS + ): + tip_fmt = metomi.rose.config_editor.VAR_COMMENT_TIP + comments = [tip_fmt.format(c) for c in self.my_variable.comments] + tooltip_text = "\n".join(comments) + comment_widgets = self.comments_box.get_children() + if comment_widgets: + comment_widgets[0].set_tooltip_text(tooltip_text) + else: + edit_eb = Gtk.EventBox() + edit_eb.show() + edit_label = Gtk.Label(label="#") + edit_label.show() + edit_eb.add(edit_label) + edit_eb.set_tooltip_text(tooltip_text) + edit_eb.connect( + "button-press-event", self._handle_comment_click + ) + edit_eb.connect( + "enter-notify-event", + self._handle_comment_enter_leave, + True, + ) + edit_eb.connect( + "leave-notify-event", + self._handle_comment_enter_leave, + False, + ) + self.comments_box.pack_start( + edit_eb, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + self.comments_box.show() + else: + self.comments_box.hide() + + def _get_metadata_formatting(self, mode): + """Apply the correct formatting for a metadata property.""" + mode_format = "{" + mode + "}" + if ( + mode == metomi.rose.META_PROP_DESCRIPTION + and self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_DESCRIPTION + ] + ): + mode_format = metomi.rose.config_editor.CUSTOM_FORMAT_DESCRIPTION + if ( + mode == metomi.rose.META_PROP_HELP + and self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP + ] + ): + mode_format = metomi.rose.config_editor.CUSTOM_FORMAT_HELP + if ( + mode == metomi.rose.META_PROP_TITLE + and self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE + ] + ): + mode_format = metomi.rose.config_editor.CUSTOM_FORMAT_TITLE + mode_string = metomi.rose.variable.expand_format_string( + mode_format, self.my_variable + ) + if mode_string is None: + return self.my_variable.metadata[mode] + return mode_string + + def _set_show_custom_meta_text(self, mode, should_show_mode): + """Set the display of a custom format for a metadata property.""" + if mode == metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE: + return self._set_show_title( + not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_NO_TITLE + ] + ) + if mode == metomi.rose.config_editor.SHOW_MODE_CUSTOM_DESCRIPTION: + is_shown = not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_NO_DESCRIPTION + ] + if is_shown: + self._set_show_meta_text_mode( + metomi.rose.META_PROP_DESCRIPTION, False + ) + self._set_show_meta_text_mode( + metomi.rose.META_PROP_DESCRIPTION, True + ) + if mode == metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP: + is_shown = not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_NO_HELP + ] + if is_shown: + self._set_show_meta_text_mode( + metomi.rose.META_PROP_HELP, False + ) + self._set_show_meta_text_mode(metomi.rose.META_PROP_HELP, True) + + def _set_show_meta_text_mode(self, mode, should_show_mode): + """Set the display of description or help below the title/name.""" + if should_show_mode: + search_func = lambda i: self.var_ops.search_for_var( + self.meta["full_ns"], i + ) + if mode not in self.meta: + return + mode_text = self._get_metadata_formatting(mode) + mode_text = metomi.rose.gtk.util.safe_str(mode_text) + mode_text = metomi.rose.config_editor.VAR_FLAG_MARKUP.format( + mode_text + ) + label = metomi.rose.gtk.util.get_hyperlink_label( + mode_text, search_func + ) + label.show() + hbox = Gtk.Box() + hbox.show() + hbox.pack_start(label, expand=False, fill=False, padding=0) + hbox.set_sensitive(self.entry.get_property("sensitive")) + hbox._show_mode = mode + # hbox.set_baseline_position(Gtk.BaselinePosition.BOTTOM) + self.pack_start( + hbox, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + show_mode_widget_indices = [] + for i, widget in enumerate(self.get_children()): + if hasattr(widget, "_show_mode"): + show_mode_widget_indices.append((widget._show_mode, i)) + show_mode_widget_indices.sort() + for j, (show_mode, i) in enumerate(show_mode_widget_indices): + if show_mode == mode and j < len(show_mode_widget_indices) - 1: + # The new widget goes before the next one alphabetically. + new_index = show_mode_widget_indices[j + 1][1] + self.reorder_child(hbox, new_index) + break + else: + for widget in self.get_children(): + if ( + isinstance(widget, Gtk.Box) + and hasattr(widget, "_show_mode") + and widget._show_mode == mode + ): + self.remove(widget) + + def _set_show_title(self, should_show_title): + """Set the display of a variable title instead of the name.""" + if not self.my_variable.name: + return False + if should_show_title: + if metomi.rose.META_PROP_TITLE in self.meta: + title_string = self._get_metadata_formatting( + metomi.rose.META_PROP_TITLE + ) + if title_string != self.entry.get_text(): + return self.entry.set_text(title_string) + if self.entry.get_text() != self.my_variable.name: + self.entry.set_text(self.my_variable.name) + + def _toggle_flag_label(self, event_box, event, text=None): + """Toggle a label describing the flag.""" + flag_type = event_box._flag_type + if text is None: + text = event_box.get_child().get_tooltip_text() + for widget in self.get_children(): + if ( + hasattr(widget, "_flag_type") + and widget._flag_type == flag_type + ): + return self.remove(widget) + label = Gtk.Label() + markup = metomi.rose.gtk.util.safe_str(text) + markup = metomi.rose.config_editor.VAR_FLAG_MARKUP.format(markup) + label.set_markup(markup) + label.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox._flag_type = flag_type + hbox.pack_start(label, expand=False, fill=False, padding=0) + hbox.set_sensitive(self.entry.get_property("sensitive")) + hbox.show() + self.pack_start(hbox, expand=False, fill=False, padding=0) + + def _edit_finish_hook(self, text): + self.var_ops.set_var_comments(self.my_variable, text.splitlines()) + self.update_status() + + def _handle_comment_enter_leave(self, widget, event, is_entering=False): + label = widget.get_child() + self._set_underline(label, underline=is_entering) + + def _handle_comment_click(self, widget, event): + if event.button == 1: + self.launch_edit_comments() + + def _handle_enter(self, event_box): + label_text = self.entry.get_text() + tooltip_text = "" + if metomi.rose.META_PROP_DESCRIPTION in self.meta: + tooltip_text = self._get_metadata_formatting( + metomi.rose.META_PROP_DESCRIPTION + ) + if metomi.rose.META_PROP_TITLE in self.meta: + if self.show_modes[metomi.rose.config_editor.SHOW_MODE_NO_TITLE]: + # Titles are hidden, so show them in the hover-over. + tooltip_text += ( + "\n (" + + metomi.rose.META_PROP_TITLE.capitalize() + + ": '" + + self.meta[metomi.rose.META_PROP_TITLE] + + "')" + ) + elif ( + self.my_variable.name not in label_text + or not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE + ] + ): + # No custom title, or a custom title without the name. + tooltip_text += "\n (" + self.my_variable.name + ")" + if self.my_variable.comments: + tip_fmt = metomi.rose.config_editor.VAR_COMMENT_TIP + if tooltip_text: + tooltip_text += "\n" + comments = [tip_fmt.format(c) for c in self.my_variable.comments] + tooltip_text += "\n".join(comments) + changes = self.var_ops.get_var_changes(self.my_variable) + if changes != "" and tooltip_text != "": + tooltip_text += "\n\n" + changes + else: + tooltip_text += changes + tooltip_text.strip() + if tooltip_text == "": + tooltip_text = None + event_box.set_tooltip_text(tooltip_text) + if ( + metomi.rose.META_PROP_URL not in self.meta + and "http://" in self.my_variable.value + ): + new_url = re.search( + "(http://[^ ]+)", self.my_variable.value + ).group() + # This is not very nice. + self.meta.update({metomi.rose.META_PROP_URL: new_url}) + if ( + metomi.rose.META_PROP_HELP in self.meta + or metomi.rose.META_PROP_URL in self.meta + ): + if isinstance(self.entry, Gtk.Label): + self._set_underline(self.entry, underline=True) + return False + + def _set_underline(self, label, underline=False): + # Set an underline in a label widget. + att_list = label.get_attributes() + if att_list is None: + att_list = Pango.AttrList() + if underline: + att_list.insert(Pango.attr_underline_new(Pango.Underline.SINGLE)) + else: + att_list = att_list.filter( + lambda a: a.klass.type != Pango.AttrType.UNDERLINE + ) + if att_list is None: + att_list = Pango.AttrList() + label.set_attributes(att_list) + + def _handle_leave(self, event_box): + event_box.set_tooltip_text(None) + if isinstance(self.entry, Gtk.Label): + self._set_underline(self.entry, underline=False) + return False + + def _set_sensitive(self, is_sensitive): + self.entry.set_sensitive(is_sensitive) + for child in self.get_children(): + if hasattr(child, "_flag_type") or hasattr(child, "_show_mode"): + child.set_sensitive(is_sensitive) + + def _setter(self, widget, variable): + """Re-set the name of the variable in the dictionary object.""" + new_name = widget.get_text() + if variable.name != new_name: + section = variable.metadata["id"].split( + metomi.rose.CONFIG_DELIMITER + )[0] + if section.startswith("namelist:"): + if new_name.lower() != new_name: + text = metomi.rose.config_editor.DIALOG_BODY_NL_CASE_CHANGE + text = text.format(new_name.lower()) + title = ( + metomi.rose.config_editor.DIALOG_TITLE_NL_CASE_WARNING + ) + new_name = metomi.rose.gtk.dialog.run_choices_dialog( + text, [new_name.lower(), new_name], title + ) + if new_name is None: + return None + self.var_ops.remove_var(variable) + variable.name = new_name + variable.metadata["id"] = ( + section + metomi.rose.CONFIG_DELIMITER + variable.name + ) + self.var_ops.add_var(variable) diff --git a/metomi/rose/config_editor/main.py b/metomi/rose/config_editor/main.py new file mode 100644 index 0000000000..22f87fed96 --- /dev/null +++ b/metomi/rose/config_editor/main.py @@ -0,0 +1,2299 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +""" +This module contains the core processing of the config editor. + +Classes: + MainController - driver for loading and central coordination. + +""" +import cProfile +import os +import pstats +import re +import shutil +import sre_constants +import sys +import tempfile +import warnings + +from functools import cmp_to_key + +# Ignore add menu related warnings for now, but remove this later. +warnings.filterwarnings( + "ignore", "instance of invalid non-instantiatable type", Warning +) +warnings.filterwarnings( + "ignore", "g_signal_handlers_disconnect_matched", Warning +) +warnings.filterwarnings("ignore", "use set_markup", Warning) +warnings.filterwarnings("ignore", "Unable to show", Warning) +warnings.filterwarnings("ignore", "gdk", Warning) + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.config_editor.data +import metomi.rose.config_editor.menu +import metomi.rose.config_editor.nav_controller +import metomi.rose.config_editor.nav_panel +import metomi.rose.config_editor.nav_panel_menu +import metomi.rose.config_editor.ops.group +import metomi.rose.config_editor.ops.section +import metomi.rose.config_editor.ops.variable +import metomi.rose.config_editor.page +import metomi.rose.config_editor.stack +import metomi.rose.config_editor.status +import metomi.rose.config_editor.updater +import metomi.rose.config_editor.util +import metomi.rose.config_editor.variable +import metomi.rose.config_editor.window +import metomi.rose.gtk.dialog +import metomi.rose.gtk.splash +import metomi.rose.gtk.util +import metomi.rose.macro +import metomi.rose.opt_parse +import metomi.rose.resource +import metomi.rose.macros + + +class MainController(object): + """The main controller class. + + Call with a configuration directory and/or a dict of + configuration names and objects. + + pluggable is a boolean that if True, returns containers for + plugging into other GTK applications. If pluggable is False, + launch the standalone application. + + load_updater is a metomi.rose.gtk.splash.SplashScreenProcess instance or + None, in which case it will be set to a + metomi.rose.gtk.splash.NullSplashScreenProcess. + + load_all_apps is a boolean that overrides the load-on-demand + automation to always load all sub configurations at start time. + + load_no_apps is a boolean that overrides the load-on-demand + automation to always skip loading sub configurations at start time. + + metadata_off is a boolean that controls whether the suite or app + should load with metadata on or off. + + """ + + RE_ARRAY_ELEMENT = re.compile(r"\([\d:, ]+\)$") + + def __init__( + self, + config_directory=None, + config_objs=None, + config_obj_types=None, + pluggable=False, + load_updater=None, + load_all_apps=False, + load_no_apps=False, + metadata_off=False, + opt_meta_paths=None, + no_warn=None, + ): + if config_objs is None: + config_objs = {} + if pluggable: + metomi.rose.macro.add_meta_paths() + if load_updater is None: + load_updater = metomi.rose.gtk.splash.NullSplashScreenProcess() + self.is_pluggable = pluggable + self.tab_windows = [] # No child windows yet + self.orphan_pages = [] + self.undo_stack = [] # Nothing to undo yet + self.redo_stack = [] # Nothing to redo yet + self.find_hist = {"regex": "", "ids": []} + self.util = metomi.rose.config_editor.util.Lookup() + self.metadata_off = metadata_off + if opt_meta_paths is None: + opt_meta_paths = [] + + # Set page variable 'verbosity' defaults. + self.page_var_show_modes = { + metomi.rose.config_editor.SHOW_MODE_CUSTOM_DESCRIPTION: ( + metomi.rose.config_editor.SHOULD_SHOW_CUSTOM_DESCRIPTION + ), + metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP: ( + metomi.rose.config_editor.SHOULD_SHOW_CUSTOM_HELP + ), + metomi.rose.config_editor.SHOW_MODE_CUSTOM_TITLE: ( + metomi.rose.config_editor.SHOULD_SHOW_CUSTOM_TITLE + ), + metomi.rose.config_editor.SHOW_MODE_FIXED: ( + metomi.rose.config_editor.SHOULD_SHOW_FIXED_VARS + ), + metomi.rose.config_editor.SHOW_MODE_FLAG_OPTIONAL: ( + metomi.rose.config_editor.SHOULD_SHOW_FLAG_OPTIONAL_VARS + ), + metomi.rose.config_editor.SHOW_MODE_FLAG_OPT_CONF: ( + metomi.rose.config_editor.SHOULD_SHOW_FLAG_OPT_CONF_VARS + ), + metomi.rose.config_editor.SHOW_MODE_FLAG_NO_META: ( + metomi.rose.config_editor.SHOULD_SHOW_FLAG_NO_META_VARS + ), + metomi.rose.config_editor.SHOW_MODE_IGNORED: ( + metomi.rose.config_editor.SHOULD_SHOW_IGNORED_VARS + ), + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED: ( + metomi.rose.config_editor.SHOULD_SHOW_USER_IGNORED_VARS + ), + metomi.rose.config_editor.SHOW_MODE_LATENT: ( + metomi.rose.config_editor.SHOULD_SHOW_LATENT_VARS + ), + metomi.rose.config_editor.SHOW_MODE_NO_DESCRIPTION: ( + metomi.rose.config_editor.SHOULD_SHOW_NO_DESCRIPTION + ), + metomi.rose.config_editor.SHOW_MODE_NO_HELP: ( + metomi.rose.config_editor.SHOULD_SHOW_NO_HELP + ), + metomi.rose.config_editor.SHOW_MODE_NO_TITLE: ( + metomi.rose.config_editor.SHOULD_SHOW_NO_TITLE + ), + } + + # Set page tree 'verbosity' defaults. + self.page_ns_show_modes = { + metomi.rose.config_editor.SHOW_MODE_IGNORED: ( + metomi.rose.config_editor.SHOULD_SHOW_IGNORED_PAGES + ), + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED: ( + metomi.rose.config_editor.SHOULD_SHOW_USER_IGNORED_PAGES + ), + metomi.rose.config_editor.SHOW_MODE_LATENT: ( + metomi.rose.config_editor.SHOULD_SHOW_LATENT_PAGES + ), + metomi.rose.config_editor.SHOW_MODE_NO_TITLE: ( + metomi.rose.config_editor.SHOULD_SHOW_NO_TITLE + ), + } + + self.reporter = metomi.rose.config_editor.status.StatusReporter( + load_updater, self.update_status_text + ) + + # Load the top configuration directory + self.data = metomi.rose.config_editor.data.ConfigDataManager( + self.util, + self.reporter, + self.page_ns_show_modes, + self.reload_namespace_tree, + opt_meta_paths=opt_meta_paths, + no_warn=no_warn, + ) + + self.nav_controller = ( + metomi.rose.config_editor.nav_controller.NavTreeManager( + self.data, self.util, self.reporter, self.tree_trigger_update + ) + ) + + self.mainwindow = metomi.rose.config_editor.window.MainWindow() + + self.section_ops = ( + metomi.rose.config_editor.ops.section.SectionOperations( + self.data, + self.util, + self.reporter, + self.undo_stack, + self.redo_stack, + self.check_cannot_enable_setting, + self.update_namespace, + self.update_namespace_sub_data, + self.update_ns_info, + update_tree_func=self.reload_namespace_tree, + view_page_func=self.view_page, + kill_page_func=self.kill_page, + ) + ) + + self.variable_ops = ( + metomi.rose.config_editor.ops.variable.VariableOperations( + self.data, + self.util, + self.reporter, + self.undo_stack, + self.redo_stack, + self.section_ops.add_section, + self.check_cannot_enable_setting, + self.update_namespace, + search_id_func=self.perform_find_by_id, + ) + ) + + self.group_ops = metomi.rose.config_editor.ops.group.GroupOperations( + self.data, + self.util, + self.reporter, + self.undo_stack, + self.redo_stack, + self.section_ops, + self.variable_ops, + self.view_page, + self.update_ns_sub_data, + self.reload_namespace_tree, + ) + + # Add in the main menu bar and tool bar handler. + self.main_handle = metomi.rose.config_editor.menu.MainMenuHandler( + self.data, + self.util, + self.reporter, + self.mainwindow, + self.undo_stack, + self.redo_stack, + self.perform_undo, + self.update_config, + self.apply_macro_transform, + self.apply_macro_validation, + self.group_ops, + self.section_ops, + self.variable_ops, + self.perform_find_by_ns_id, + ) + + # Add in the navigation panel menu handler. + self.nav_handle = ( + metomi.rose.config_editor.nav_panel_menu.NavPanelHandler( + self.data, + self.util, + self.reporter, + self.mainwindow, + self.undo_stack, + self.redo_stack, + self._add_config, + self.group_ops, + self.section_ops, + self.variable_ops, + self.kill_page, + self.reload_namespace_tree, + self.main_handle.transform_default, + self.main_handle.launch_graph, + ) + ) + + self.updater = metomi.rose.config_editor.updater.Updater( + self.data, + self.util, + self.reporter, + self.mainwindow, + self.main_handle, + self.nav_controller, + self._get_pagelist, + self.update_bar_widgets, + self._refresh_metadata_if_on, + self.is_pluggable, + ) + + self.data.load( + config_directory, + config_objs, + config_obj_type_dict=config_obj_types, + load_all_apps=load_all_apps, + load_no_apps=load_no_apps, + ) + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_STATUSES.format( + self.data.top_level_name + ) + ) + if not self.is_pluggable: + self.generate_toolbar() + self.generate_menubar() + self.generate_nav_panel() + self.generate_status_bar() + # Create notebook (tabbed container) and connect signals. + self.notebook = metomi.rose.gtk.util.Notebook() + self.updater.nav_panel = getattr(self, "nav_panel", None) + # Create the main panel with the menu, toolbar, tree panel, notebook. + if not self.is_pluggable: + self.mainwindow.load( + name=self.data.top_level_name, + menu=self.top_menu, + accelerators=self.menubar.accelerators, + toolbar=self.toolbar, + nav_panel=self.nav_panel, + status_bar=self.status_bar, + notebook=self.notebook, + page_change_func=self.handle_page_change, + save_func=self.save_to_file, + ) + self.mainwindow.window.connect("destroy", self.main_handle.destroy) + self.mainwindow.window.connect( + "delete-event", self.main_handle.destroy + ) + self.mainwindow.window.connect_after( + "grab_focus", self.handle_page_change + ) + self.mainwindow.window.connect_after( + "focus-in-event", self.handle_page_change + ) + self.updater.update_all(is_loading=True) + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_ERRORS.format( + self.data.top_level_name, self.updater.load_errors + ) + ) + self.updater.perform_startup_check() + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_DONE.format( + self.data.top_level_name + ) + ) + if self.data.top_level_directory is None and not self.data.config: + self.load_from_file() + + self.update_bar_widgets() + + self.performing_undo = False + + # ----------------- Setting up main component functions ------------------- + + def generate_toolbar(self): + """Link in the toolbar functionality.""" + self.toolbar = metomi.rose.gtk.util.ToolBar( + widgets=[ + (metomi.rose.config_editor.TOOLBAR_OPEN, "Gtk.STOCK_OPEN"), + (metomi.rose.config_editor.TOOLBAR_SAVE, "Gtk.STOCK_SAVE"), + ( + metomi.rose.config_editor.TOOLBAR_CHECK_AND_SAVE, + "Gtk.STOCK_SPELL_CHECK", + ), + ( + metomi.rose.config_editor.TOOLBAR_LOAD_APPS, + "Gtk.STOCK_CDROM", + ), + ( + metomi.rose.config_editor.TOOLBAR_BROWSE, + "Gtk.STOCK_DIRECTORY", + ), + (metomi.rose.config_editor.TOOLBAR_UNDO, "Gtk.STOCK_UNDO"), + (metomi.rose.config_editor.TOOLBAR_REDO, "Gtk.STOCK_REDO"), + (metomi.rose.config_editor.TOOLBAR_ADD, "Gtk.STOCK_ADD"), + ( + metomi.rose.config_editor.TOOLBAR_REVERT, + "Gtk.STOCK_REVERT_TO_SAVED", + ), + (metomi.rose.config_editor.TOOLBAR_FIND, "Gtk.SearchEntry"), + ( + metomi.rose.config_editor.TOOLBAR_FIND_NEXT, + "Gtk.STOCK_FIND", + ), + ( + metomi.rose.config_editor.TOOLBAR_VALIDATE, + "dialog-question", + ), + ( + metomi.rose.config_editor.TOOLBAR_TRANSFORM, + "Gtk.STOCK_CONVERT", + ), + ], + sep_on_name=[ + metomi.rose.config_editor.TOOLBAR_CHECK_AND_SAVE, + metomi.rose.config_editor.TOOLBAR_BROWSE, + metomi.rose.config_editor.TOOLBAR_REDO, + metomi.rose.config_editor.TOOLBAR_REVERT, + metomi.rose.config_editor.TOOLBAR_FIND_NEXT, + metomi.rose.config_editor.TOOLBAR_TRANSFORM, + ], + ) + assign = self.toolbar.set_widget_function + assign(metomi.rose.config_editor.TOOLBAR_OPEN, self.load_from_file) + assign(metomi.rose.config_editor.TOOLBAR_SAVE, self.save_to_file) + assign( + metomi.rose.config_editor.TOOLBAR_CHECK_AND_SAVE, + self.save_to_file, + [None, True], + ) + assign( + metomi.rose.config_editor.TOOLBAR_LOAD_APPS, self.handle_load_all + ) + assign( + metomi.rose.config_editor.TOOLBAR_BROWSE, + self.main_handle.launch_browser, + ) + assign(metomi.rose.config_editor.TOOLBAR_UNDO, self.perform_undo) + assign( + metomi.rose.config_editor.TOOLBAR_REDO, self.perform_undo, [True] + ) + assign( + metomi.rose.config_editor.TOOLBAR_REVERT, self.revert_to_saved_data + ) + assign(metomi.rose.config_editor.TOOLBAR_FIND_NEXT, self._launch_find) + assign( + metomi.rose.config_editor.TOOLBAR_VALIDATE, + self.main_handle.check_all_extra, + ) + assign( + metomi.rose.config_editor.TOOLBAR_TRANSFORM, + self.main_handle.transform_default, + ) + self.find_entry = self.toolbar.item_dict.get( + metomi.rose.config_editor.TOOLBAR_FIND + )["widget"] + self.find_entry.connect("activate", self._launch_find) + self.find_entry.connect("changed", self._clear_find) + Gtk.Entry.set_placeholder_text(self.find_entry, "Search") + add_icon = self.toolbar.item_dict.get( + metomi.rose.config_editor.TOOLBAR_ADD + )["widget"] + add_icon.connect("button_press_event", self.add_page_variable) + + def generate_menubar(self): + """Link in the menu functionality and accelerators.""" + self.menubar = metomi.rose.config_editor.menu.MenuBar() + self.menu_widgets = {} + menu_list = [ + ("/TopMenuBar/File/Open...", self.load_from_file), + ("/TopMenuBar/File/Save", lambda m: self.save_to_file()), + ( + "/TopMenuBar/File/Check and save", + lambda m: self.save_to_file(check_on_save=True), + ), + ( + "/TopMenuBar/File/Load All Apps", + lambda m: self.handle_load_all(), + ), + ("/TopMenuBar/File/Quit", self.main_handle.destroy), + ("/TopMenuBar/Edit/Undo", lambda m: self.perform_undo()), + ( + "/TopMenuBar/Edit/Redo", + lambda m: self.perform_undo(redo_mode_on=True), + ), + ("/TopMenuBar/Edit/Find", self._launch_find), + ( + "/TopMenuBar/Edit/Find Next", + lambda m: self.perform_find(self.find_hist["regex"]), + ), + ("/TopMenuBar/Edit/Preferences", self.main_handle.prefs), + ("/TopMenuBar/Edit/Stack", self.main_handle.view_stack), + ( + "/TopMenuBar/View/View fixed vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_FIXED, m.get_active() + ), + ), + ( + "/TopMenuBar/View/View ignored vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_IGNORED, m.get_active() + ), + ), + ( + "/TopMenuBar/View/View user-ignored vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED, + m.get_active(), + ), + ), + ( + "/TopMenuBar/View/View latent vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_LATENT, m.get_active() + ), + ), + ( + "/TopMenuBar/View/View ignored pages", + lambda m: self._set_page_ns_show_modes( + metomi.rose.config_editor.SHOW_MODE_IGNORED, m.get_active() + ), + ), + ( + "/TopMenuBar/View/View user-ignored pages", + lambda m: self._set_page_ns_show_modes( + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED, + m.get_active(), + ), + ), + ( + "/TopMenuBar/View/View latent pages", + lambda m: self._set_page_ns_show_modes( + metomi.rose.config_editor.SHOW_MODE_LATENT, m.get_active() + ), + ), + ( + "/TopMenuBar/View/Flag no-metadata vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_FLAG_NO_META, + m.get_active(), + ), + ), + ( + "/TopMenuBar/View/Flag opt config vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_FLAG_OPT_CONF, + m.get_active(), + ), + ), + ( + "/TopMenuBar/View/Flag optional vars", + lambda m: self._set_page_var_show_modes( + metomi.rose.config_editor.SHOW_MODE_FLAG_OPTIONAL, + m.get_active(), + ), + ), + ( + "/TopMenuBar/View/View status bar", + lambda m: self._set_show_status_bar(m.get_active()), + ), + ( + "/TopMenuBar/Metadata/Prefs/View without descriptions", + lambda m: self._set_page_show_modes( + metomi.rose.config_editor.SHOW_MODE_NO_DESCRIPTION, + m.get_active(), + ), + ), + ( + "/TopMenuBar/Metadata/Prefs/View without help", + lambda m: self._set_page_show_modes( + metomi.rose.config_editor.SHOW_MODE_NO_HELP, m.get_active() + ), + ), + ( + "/TopMenuBar/Metadata/Prefs/View without titles", + lambda m: self._set_page_show_modes( + metomi.rose.config_editor.SHOW_MODE_NO_TITLE, + m.get_active(), + ), + ), + ( + "/TopMenuBar/Metadata/All V", + lambda m: self.main_handle.handle_run_custom_macro( + method_name=metomi.rose.macro.VALIDATE_METHOD + ), + ), + ( + "/TopMenuBar/Metadata/Autofix", + lambda m: self.main_handle.transform_default(), + ), + ( + "/TopMenuBar/Metadata/Extra checks", + lambda m: self.main_handle.check_fail_rules(), + ), + ( + "/TopMenuBar/Metadata/Graph", + lambda m: self.main_handle.handle_graph(), + ), + ( + "/TopMenuBar/Metadata/Reload metadata", + lambda m: self._refresh_metadata_if_on(), + ), + ( + "/TopMenuBar/Metadata/Load custom metadata", + lambda m: self.load_custom_metadata(), + ), + ( + "/TopMenuBar/Metadata/Switch off metadata", + lambda m: self.refresh_metadata(m.get_active()), + ), + ( + "/TopMenuBar/Metadata/Upgrade", + lambda m: self.main_handle.handle_upgrade(), + ), + ( + "/TopMenuBar/Tools/Browser", + lambda m: self.main_handle.launch_browser(), + ), + ( + "/TopMenuBar/Tools/Terminal", + lambda m: self.main_handle.launch_terminal(), + ), + ("/TopMenuBar/Page/Revert", lambda m: self.revert_to_saved_data()), + ( + "/TopMenuBar/Page/Page Info", + lambda m: self.nav_handle.info_request( + self._get_current_page().namespace + ), + ), + ( + "/TopMenuBar/Page/Page Help", + lambda m: self._get_current_page().launch_help(), + ), + ( + "/TopMenuBar/Page/Page Web Help", + lambda m: self._get_current_page().launch_url(), + ), + ("/TopMenuBar/Help/Documentation", self.main_handle.help), + ("/TopMenuBar/Help/About", self.main_handle.about_dialog), + ] + is_toggled = dict( + [ + ( + "/TopMenuBar/View/View fixed vars", + metomi.rose.config_editor.SHOULD_SHOW_FIXED_VARS, + ), + ( + "/TopMenuBar/View/View ignored vars", + metomi.rose.config_editor.SHOULD_SHOW_IGNORED_VARS, + ), + ( + "/TopMenuBar/View/View user-ignored vars", + metomi.rose.config_editor.SHOULD_SHOW_USER_IGNORED_VARS, + ), + ( + "/TopMenuBar/View/View latent vars", + metomi.rose.config_editor.SHOULD_SHOW_LATENT_VARS, + ), + ( + "/TopMenuBar/Metadata/Prefs/View without descriptions", + metomi.rose.config_editor.SHOULD_SHOW_NO_DESCRIPTION, + ), + ( + "/TopMenuBar/Metadata/Prefs/View without help", + metomi.rose.config_editor.SHOULD_SHOW_NO_HELP, + ), + ( + "/TopMenuBar/Metadata/Prefs/View without titles", + metomi.rose.config_editor.SHOULD_SHOW_NO_TITLE, + ), + ( + "/TopMenuBar/View/View ignored pages", + metomi.rose.config_editor.SHOULD_SHOW_IGNORED_PAGES, + ), + ( + "/TopMenuBar/View/View user-ignored pages", + metomi.rose.config_editor.SHOULD_SHOW_USER_IGNORED_PAGES, + ), + ( + "/TopMenuBar/View/View latent pages", + metomi.rose.config_editor.SHOULD_SHOW_LATENT_PAGES, + ), + ( + "/TopMenuBar/View/Flag opt config vars", + metomi.rose.config_editor.SHOULD_SHOW_FLAG_OPT_CONF_VARS, + ), + ( + "/TopMenuBar/View/Flag optional vars", + metomi.rose.config_editor.SHOULD_SHOW_FLAG_OPTIONAL_VARS, + ), + ( + "/TopMenuBar/View/Flag no-metadata vars", + metomi.rose.config_editor.SHOULD_SHOW_FLAG_NO_META_VARS, + ), + ( + "/TopMenuBar/View/View status bar", + metomi.rose.config_editor.SHOULD_SHOW_STATUS_BAR, + ), + ( + "/TopMenuBar/Metadata/Switch off metadata", + self.metadata_off, + ), + ] + ) + for address, action in menu_list: + widget = self.menubar.uimanager.get_widget(address) + self.menu_widgets.update({address: widget}) + if address in is_toggled: + widget.set_active(is_toggled[address]) + if ( + address.endswith("View user-ignored pages") + and metomi.rose.config_editor.SHOULD_SHOW_IGNORED_PAGES + ): + widget.set_sensitive(False) + if ( + address.endswith("View user-ignored vars") + and metomi.rose.config_editor.SHOULD_SHOW_IGNORED_VARS + ): + widget.set_sensitive(False) + if address.endswith("Reload metadata") and self.metadata_off: + widget.set_sensitive(False) + widget.connect("activate", action) + page_menu = self.menubar.uimanager.get_widget("/TopMenuBar/Page") + add_menuitem = self.menubar.uimanager.get_widget( + "/TopMenuBar/Page/Add variable" + ) + page_menu.connect( + "activate", + lambda m: self.main_handle.load_page_menu( + self.menubar, add_menuitem, self._get_current_page() + ), + ) + page_menu.get_submenu().connect( + "deactivate", + lambda m: self.main_handle.clear_page_menu( + self.menubar, add_menuitem + ), + ) + self.main_handle.load_macro_menu(self.menubar) + self.update_bar_widgets() + self.top_menu = self.menubar.uimanager.get_widget("/TopMenuBar") + # Load the keyboard accelerators. + accel = { + metomi.rose.config_editor.ACCEL_UNDO: self.perform_undo, + metomi.rose.config_editor.ACCEL_REDO: lambda: self.perform_undo( + redo_mode_on=True + ), + metomi.rose.config_editor.ACCEL_FIND: self.find_entry.grab_focus, + metomi.rose.config_editor.ACCEL_FIND_NEXT: ( + lambda: self.perform_find(self.find_hist["regex"]) + ), + metomi.rose.config_editor.ACCEL_HELP_GUI: self.main_handle.help, + metomi.rose.config_editor.ACCEL_OPEN: self.load_from_file, + metomi.rose.config_editor.ACCEL_SAVE: self.save_to_file, + metomi.rose.config_editor.ACCEL_QUIT: self.main_handle.destroy, + metomi.rose.config_editor.ACCEL_METADATA_REFRESH: ( + self._refresh_metadata_if_on + ), + metomi.rose.config_editor.ACCEL_BROWSER: ( + self.main_handle.launch_browser + ), + metomi.rose.config_editor.ACCEL_TERMINAL: ( + self.main_handle.launch_terminal + ), + } + self.menubar.set_accelerators(accel) + + def generate_nav_panel(self): + """ "Create tree panel and link functions.""" + self.nav_panel = ( + metomi.rose.config_editor.nav_panel.PageNavigationPanel( + self.nav_controller.namespace_tree, + self.handle_launch_request, + self.nav_handle.get_ns_metadata_and_comments, + self.nav_handle.popup_panel_menu, + self.nav_handle.get_can_show_page, + self.nav_handle.ask_is_preview, + ) + ) + + def generate_status_bar(self): + """Create a status bar.""" + self.status_bar = metomi.rose.config_editor.status.StatusBar( + verbosity=metomi.rose.config_editor.STATUS_BAR_VERBOSITY + ) + self._set_show_status_bar( + metomi.rose.config_editor.SHOULD_SHOW_STATUS_BAR + ) + + # ----------------- Page manipulation functions --------------------------- + + def handle_load_all(self, *args): + """Handle a request to load all preview configurations.""" + load_these = [] + for item in list(self.data.config.keys()): + if self.data.config[item].is_preview: + load_these.append(item) + load_these.sort() + number_of_events = ( + len(load_these) * metomi.rose.config_editor.LOAD_NUMBER_OF_EVENTS + + 2 + ) + self.reporter.report_load_event( + "Loading all preview apps", new_total_events=number_of_events + ) + for namespace_name in load_these: + config_data = self.data.config[namespace_name] + self.data.load_config( + config_data.directory, + preview=False, + metadata_off=self.metadata_off, + ) + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOADED.format( + namespace_name[1:] + ), + no_progress=True, + ) + self.reload_namespace_tree() + self.reporter.stop() + self.nav_panel.update_row_tooltips() + if hasattr(self, "menubar"): + self.main_handle.load_macro_menu(self.menubar) + self.update_bar_widgets() + self.updater.perform_startup_check() + return + + def handle_launch_request(self, namespace_name, as_new=False): + """Handle a request to create a page. + + It normally returns a page containing all variables associated with + the namespace namespace_name, but it won't create a page if it is + already open. It will overwrite the existing current page, if any, + in the internal notebook, unless as_new is True. + + """ + if not namespace_name.startswith("/"): + namespace_name = "/" + namespace_name + + config_name = self.util.split_full_ns(self.data, namespace_name)[0] + config_data = self.data.config[config_name] + + if config_data.is_preview: + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOAD_ATTEMPT.format( + namespace_name + ), + new_total_events=3, + ) + self.data.load_config( + config_data.directory, + preview=False, + metadata_off=self.metadata_off, + ) + self.reload_namespace_tree() + self.nav_panel.update_row_tooltips() + self.reporter.report_load_event( + metomi.rose.config_editor.EVENT_LOADED.format(namespace_name), + no_progress=True, + ) + self.reporter.stop() + if hasattr(self, "menubar"): + self.main_handle.load_macro_menu(self.menubar) + self.update_bar_widgets() + self.updater.perform_startup_check() + + if namespace_name in self.notebook.get_page_ids(): + index = self.notebook.get_page_ids().index(namespace_name) + self.notebook.set_current_page(index) + return False + for tab_window in self.tab_windows: + if tab_window.get_child().namespace == namespace_name: + tab_window.present() + return False + page = self.make_page(namespace_name) + if page is None: + return False + if as_new: + self.notebook.append_page(page, page.labelwidget) + self.notebook.set_current_page(-1) + else: + index = self.notebook.get_current_page() + self.notebook.insert_page(page, page.labelwidget, index) + self.notebook.set_current_page(index) + if index != -1: + self.notebook.remove_page(index + 1) + self.notebook.set_tab_label_packing(page, page.labelwidget) + + def make_page(self, namespace_name): + """Look up page data and attributes and call a page constructor.""" + config_name, subspace = self.util.split_full_ns( + self.data, namespace_name + ) + data, latent_data = self.data.helper.get_data_for_namespace( + namespace_name + ) + config_data = self.data.config[config_name] + ns_metadata = self.data.namespace_meta_lookup.get(namespace_name, {}) + description = ns_metadata.get(metomi.rose.META_PROP_DESCRIPTION, "") + duplicate = ns_metadata.get(metomi.rose.META_PROP_DUPLICATE) + help_ = ns_metadata.get(metomi.rose.META_PROP_HELP) + url = ns_metadata.get(metomi.rose.META_PROP_URL) + custom_widget = ns_metadata.get( + metomi.rose.config_editor.META_PROP_WIDGET + ) + custom_sub_widget = ns_metadata.get( + metomi.rose.config_editor.META_PROP_WIDGET_SUB_NS + ) + has_sub_data = self.data.helper.is_ns_sub_data(namespace_name) + label = ns_metadata.get(metomi.rose.META_PROP_TITLE) + if label is None: + label = subspace.split("/")[-1] + if duplicate == metomi.rose.META_PROP_VALUE_TRUE and not has_sub_data: + # For example, namelist/foo/1 should be shown as foo(1). + label = "(".join(subspace.split("/")[-2:]) + ")" + section_data_objects, latent_section_data_objects = ( + self.data.helper.get_section_data_for_namespace(namespace_name) + ) + # Related pages + see_also = "" + sections = [s for s in ns_metadata.get("sections", [])] + for section_name in [s for s in sections if s.startswith("namelist")]: + no_num_name = metomi.rose.macro.REC_ID_STRIP_DUPL.sub( + "", section_name + ) + no_mod_name = metomi.rose.macro.REC_ID_STRIP.sub("", section_name) + ok_names = [section_name, no_num_name + "(:)", no_mod_name + "(:)"] + if no_mod_name != no_num_name: + # There's a modifier in the section name. + ok_names.append(no_num_name) + for section, variables in list(config_data.vars.now.items()): + if not section.startswith(metomi.rose.SUB_CONFIG_FILE_DIR): + continue + for variable in variables: + if variable.name != metomi.rose.FILE_VAR_SOURCE: + continue + var_values = metomi.rose.variable.array_split( + variable.value + ) + for i, val in enumerate(var_values): + if val.startswith("(") and val.endswith(")"): + # It is optional - e.g. "(namelist:baz)". + var_values[i] = val[1:-1] + if set(ok_names) & set(var_values): + var_id = variable.metadata["id"] + see_also += ", " + var_id + see_also = see_also.replace(", ", "", 1) + # Icon + icon_path = self.data.helper.get_icon_path_for_config(config_name) + is_default = self.data.helper.get_ns_is_default(namespace_name) + sub_data = None + sub_ops = None + if has_sub_data: + sub_data = self.data.helper.get_sub_data_for_namespace( + namespace_name + ) + sub_ops = self.group_ops.get_sub_ops_for_namespace(namespace_name) + macro_info = self.data.helper.get_macro_info_for_namespace( + namespace_name + ) + page_metadata = { + "namespace": namespace_name, + "ns_is_default": is_default, + "label": label, + "description": description, + "duplicate": duplicate, + "help": help_, + "url": url, + "macro": macro_info, + "widget": custom_widget, + "widget_sub_ns": custom_sub_widget, + "see_also": see_also, + "config_name": config_name, + "show_modes": self.page_var_show_modes, + "icon": icon_path, + } + if len(sections) == 1: + page_metadata.update({"id": sections.pop()}) + sect_ops = metomi.rose.config_editor.ops.section.SectionOperations( + self.data, + self.util, + self.reporter, + self.undo_stack, + self.redo_stack, + self.check_cannot_enable_setting, + self.updater.update_namespace, + self.updater.update_ns_sub_data, + self.updater.update_ns_info, + update_tree_func=self.reload_namespace_tree, + view_page_func=self.view_page, + kill_page_func=self.kill_page, + ) + var_ops = metomi.rose.config_editor.ops.variable.VariableOperations( + self.data, + self.util, + self.reporter, + self.undo_stack, + self.redo_stack, + sect_ops.add_section, + self.check_cannot_enable_setting, + self.updater.update_namespace, + search_id_func=self.perform_find_by_id, + ) + directory = None + if namespace_name == config_name: + directory = config_data.directory + launch_info = lambda: self.nav_handle.info_request(namespace_name) + launch_edit = lambda: self.nav_handle.edit_request(namespace_name) + page = metomi.rose.config_editor.page.ConfigPage( + page_metadata, + data, + latent_data, + sect_ops, + var_ops, + section_data_objects, + latent_section_data_objects, + self.data.helper.get_format_sections, + self.reporter, + directory, + sub_data=sub_data, + sub_ops=sub_ops, + launch_info_func=launch_info, + launch_edit_func=launch_edit, + launch_macro_func=self.main_handle.handle_run_custom_macro, + ) + # FIXME: These three should go. + page.trigger_tab_detach = lambda b: self._handle_detach_request(page) + var_ops.trigger_ignored_update = lambda v: page.update_ignored() + page.trigger_update_status = lambda: self.updater.update_status(page) + return page + + def get_orphan_page(self, namespace): + """Return a page widget for embedding somewhere else.""" + page = self.make_page(namespace) + orphan_container = self.main_handle.get_orphan_container(page) + self.orphan_pages.append(page) + return orphan_container + + def _handle_detach_request(self, page, old_window=None): + """Open tab (or 'page') in a window and manage close page events.""" + if old_window is None: + tab_window = Gtk.Window() + tab_window.set_icon(self.mainwindow.window.get_icon()) + tab_window.add_accel_group(self.menubar.accelerators) + tab_window.set_default_size( + *metomi.rose.config_editor.SIZE_PAGE_DETACH + ) + tab_window.connect( + "destroy-event", + lambda w, e: self.tab_windows.remove(w) and False, + ) + tab_window.connect( + "delete-event", + lambda w, e: self.tab_windows.remove(w) and False, + ) + else: + tab_window = old_window + add_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_ADD, + tip_text=metomi.rose.config_editor.TIP_ADD_TO_PAGE, + size=Gtk.IconSize.LARGE_TOOLBAR, + as_tool=True, + ) + revert_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_REVERT_TO_SAVED, + tip_text=metomi.rose.config_editor.TIP_REVERT_PAGE, + size=Gtk.IconSize.LARGE_TOOLBAR, + as_tool=True, + ) + add_button.connect("button_press_event", self.add_page_variable) + revert_button.connect("clicked", lambda b: self.revert_to_saved_data()) + if old_window is None: + parent = self.notebook + else: + parent = old_window + page.reshuffle_for_detached(add_button, revert_button, parent) + tab_window.set_title( + " - ".join( + [ + page.label, + self.data.top_level_name, + metomi.rose.config_editor.PROGRAM_NAME, + ] + ) + ) + tab_window.add(page) + tab_window.connect_after("focus-in-event", self.handle_page_change) + if old_window is None: + self.tab_windows.append(tab_window) + tab_window.show() + tab_window.present() + self.set_current_page_indicator(page.namespace) + return False + + def handle_page_change(self, *args): + """Handle a page change and select the correct tree row.""" + current_page = self._get_current_page() + self.update_page_bar_sensitivity(current_page) + if current_page is None: + self.nav_panel.select_row(None) + return False + self.set_current_page_indicator(current_page.namespace) + return False + + def update_page_bar_sensitivity(self, current_page): + """Update the top 'Page' menu and the toolbar.""" + if not hasattr(self, "toolbar") or not hasattr(self, "menubar"): + return False + page_icons = ["Add to page...", "Revert page to saved"] + get_widget = self.menubar.uimanager.get_widget + page_menu = get_widget("/TopMenuBar/Page") + page_menuitems = page_menu.get_submenu().get_children() + if current_page is None or not self.notebook.get_n_pages(): + for name in page_icons: + self.toolbar.set_widget_sensitive(name, False) + for menuitem in page_menuitems: + menuitem.set_sensitive(False) + else: + for name in page_icons: + self.toolbar.set_widget_sensitive(name, True) + for menuitem in page_menuitems: + menuitem.set_sensitive(True) + ns = current_page.namespace + metadata = self.data.namespace_meta_lookup.get(ns, {}) + get_widget("/TopMenuBar/Page/Page Help").set_sensitive( + metomi.rose.META_PROP_HELP in metadata + ) + get_widget("/TopMenuBar/Page/Page Web Help").set_sensitive( + metomi.rose.META_PROP_URL in metadata + ) + + def set_current_page_indicator(self, namespace): + """Make sure the current page is highlighted in the nav panel.""" + if hasattr(self, "nav_panel"): + self.nav_panel.select_row(namespace.lstrip("/").split("/")) + + def add_page_variable(self, widget, event): + """Launch an add menu based on page content.""" + page = self._get_current_page() + if page is None: + return False + page.launch_add_menu(event) + + def revert_to_saved_data(self): + """Reload the page data from saved configuration information.""" + page = self._get_current_page() + if page is None: + return + namespace = page.namespace + config_name = self.util.split_full_ns(self.data, namespace)[0] + self.data.load_node_namespaces(config_name, from_saved=True) + config_data, ghost_data = self.data.helper.get_data_for_namespace( + namespace, from_saved=True + ) + page.reload_from_data(config_data, ghost_data) + self.data.load_node_namespaces(config_name) + self.updater.update_status(page) + self.reporter.report( + metomi.rose.config_editor.EVENT_REVERT.format( + namespace.lstrip("/") + ) + ) + + def _get_pagelist(self): + """Load an attribute self.pagelist with a list of open pages.""" + self.pagelist = [] + if hasattr(self, "notebook"): + for index in range(self.notebook.get_n_pages()): + if hasattr(self.notebook.get_nth_page(index), "panel_data"): + self.pagelist.append(self.notebook.get_nth_page(index)) + if hasattr(self, "tab_windows"): + for window in self.tab_windows: + if hasattr(window.get_child(), "panel_data"): + self.pagelist.append(window.get_child()) + self.pagelist.extend(self.orphan_pages) + return self.pagelist + + def _get_current_page(self): + """Return the currently focused page.""" + self._get_pagelist() + if not self.pagelist: + return None + for window in self.tab_windows: + if window.has_toplevel_focus(): + return window.get_child() + for page in self.orphan_pages: + if page.get_toplevel().is_active(): + return page + if hasattr(self, "notebook"): + index = self.notebook.get_current_page() + return self.notebook.get_nth_page(index) + return None + + def _get_current_page_and_id(self): + """Return the currently focused page and the variable id (if any).""" + page = self._get_current_page() + if page is None: + return None, None + return page, page.get_main_focus() + + def _set_show_status_bar(self, should_show_status_bar): + """Set whether the status bar is shown or hidden.""" + if hasattr(self, "status_bar") and self.status_bar is not None: + if should_show_status_bar: + self.status_bar.show() + else: + self.status_bar.hide() + + def _set_page_show_modes(self, key, is_key_allowed): + """Set generic variable/namespace view options.""" + self._set_page_var_show_modes(key, is_key_allowed) + self._set_page_ns_show_modes(key, is_key_allowed) + + def _set_page_ns_show_modes(self, key, is_key_allowed): + """Set namespace view options.""" + self.page_ns_show_modes[key] = is_key_allowed + if ( + hasattr(self, "menubar") + and key == metomi.rose.config_editor.SHOW_MODE_IGNORED + ): + user_ign_item = self.menubar.uimanager.get_widget( + "/TopMenuBar/View/View user-ignored pages" + ) + user_ign_item.set_sensitive(not is_key_allowed) + + def _set_page_var_show_modes(self, key, is_key_allowed): + """Set variable widgets' view options.""" + self.page_var_show_modes[key] = is_key_allowed + self._get_pagelist() + for page in self.pagelist: + page.react_to_show_modes(key, is_key_allowed) + if ( + hasattr(self, "menubar") + and key == metomi.rose.config_editor.SHOW_MODE_IGNORED + ): + user_ign_item = self.menubar.uimanager.get_widget( + "/TopMenuBar/View/View user-ignored vars" + ) + user_ign_item.set_sensitive(not is_key_allowed) + + def kill_page(self, namespace): + """Destroy a page if it has the same namespace as the argument.""" + self._get_pagelist() + for page in self.pagelist: + if page.namespace == namespace: + if page.namespace in self.notebook.get_page_ids(): + self.notebook.delete_by_id(page.namespace) + else: + tab_pages = [w.get_child() for w in self.tab_windows] + if page in tab_pages: + page_window = self.tab_windows[tab_pages.index(page)] + page_window.destroy() + self.tab_windows.remove(page_window) + else: + self.orphan_pages.remove(page) + + # ----------------- Update functions -------------------------------------- + + def reload_namespace_tree(self, *args, **kwargs): + """Redraw the navigation namespace tree.""" + self.nav_controller.reload_namespace_tree(*args, **kwargs) + + def tree_trigger_update(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.tree_trigger_update(*args, **kwargs) + + def update_config(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.update_config(*args, **kwargs) + + def update_namespace(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.update_namespace(*args, **kwargs) + + def update_namespace_sub_data(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.update_ns_sub_data(*args, **kwargs) + + def update_ns_info(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.update_ns_info(*args, **kwargs) + + def update_ns_sub_data(self, *args, **kwargs): + """Placeholder for updater function of the same name.""" + self.updater.update_ns_sub_data(*args, **kwargs) + + # ----------------- Page viewer function ---------------------------------- + + def view_page(self, page_id, var_id=None): + """Set focus by namespace (page_id), and optionally by var key.""" + page = None + if page_id is None: + return None + current_page = self._get_current_page() + if current_page is not None and current_page.namespace == page_id: + current_page.set_main_focus(var_id) + self.handle_page_change() # Just to make sure. + return current_page + self._get_pagelist() + if page_id not in [p.namespace for p in self.pagelist]: + self.handle_launch_request(page_id, as_new=True) + index = self.notebook.get_current_page() + page = self.notebook.get_nth_page(index) + if page_id in self.notebook.get_page_ids(): + index = self.notebook.get_page_ids().index(page_id) + page = self.notebook.get_nth_page(index) + self.notebook.set_current_page(index) + if not self.mainwindow.window.is_active(): + self.mainwindow.window.present() + page.set_main_focus(var_id) + else: + for tab_window in self.tab_windows: + if tab_window.get_child().namespace == page_id: + page = tab_window.get_child() + if not tab_window.is_active(): + tab_window.present() + page.set_main_focus(var_id) + self.set_current_page_indicator(page_id) + return page + + # ----------------- Primary menu functions -------------------------------- + + def load_from_file(self, somewidget=None): + """Open a standard dialogue and load a config file, if selected.""" + dirname = self.mainwindow.launch_open_dirname_dialog() + if dirname is None or not os.path.isdir(dirname): + return False + if self.data.top_level_directory is None and not self.is_pluggable: + self.data.load_top_config(dirname) + self.data.saved_config_names = set(self.data.config.keys()) + self.mainwindow.window.set_title( + self.data.top_level_name + " - rose-config-editor" + ) + self.updater.update_all() + self.updater.perform_startup_check() + else: + spawn_subprocess_window(dirname) + + def save_to_file(self, only_config_name=None, check_on_save=False): + """Dump the component configurations in memory to disk.""" + if only_config_name is None: + config_names = [] + for config_name in list(self.data.config.keys()): + if not self.data.config[config_name].is_preview: + config_names.append(config_name) + else: + config_names = [only_config_name] + save_ok = True + if check_on_save: + self.main_handle.check_all_extra() + + for config_name in sorted(config_names): + short_config_name = config_name.lstrip("/") + config = self.data.dump_to_internal_config(config_name) + new_save_config = self.data.dump_to_internal_config(config_name) + config_data = self.data.config[config_name] + vars_ok = True + for var in config_data.vars.get_all(skip_latent=True): + if not var.name: + self.view_page(var.metadata["full_ns"], var.metadata["id"]) + page_address = var.metadata["full_ns"].lstrip("/") + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.ERROR_SAVE_BLANK.format( + short_config_name, page_address + ), + title=metomi.rose.config_editor.ERROR_SAVE_TITLE.format( # noqa: E501 + short_config_name + ), + modal=False, + ) + vars_ok = False + break + if not vars_ok: + save_ok = False + continue + directory = config_data.directory + config_vars = config_data.vars + config_sections = config_data.sections + + # Run check fail-if, warn-if and validator macros if check_on_save + if check_on_save: + errors = self.nav_panel.get_change_error_totals( + config_name=short_config_name + )[1] + if errors > 0: + dialog = Gtk.MessageDialog( + None, + Gtk.DialogFlags.MODAL + | Gtk.DialogFlags.DESTROY_WITH_PARENT, + Gtk.MessageType.INFO, + Gtk.ButtonsType.YES_NO, + None, + ) + dialog.set_markup( + metomi.rose.config_editor.WARNING_ERRORS_FOUND_ON_SAVE.format( # noqa: E501 + short_config_name + ) + ) + res = dialog.run() + dialog.destroy() + if res == Gtk.ResponseType.NO: + continue + + # Dump the configuration. + filename = config_data.config_type + if ( + directory is None + and config_data.config_type == metomi.rose.INFO_CONFIG_NAME + ): + directory = self.data.top_level_directory + save_path = os.path.join(directory, filename) + metomi.rose.macro.pretty_format_config(config, ignore_error=True) + try: + metomi.rose.config.dump(config, save_path) + except (OSError, IOError) as exc: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.ERROR_SAVE_PATH_FAIL.format(exc), + title=metomi.rose.config_editor.ERROR_SAVE_TITLE.format( + short_config_name + ), + modal=False, + ) + save_ok = False + continue + # Un-prettify. + config = self.data.dump_to_internal_config(config_name) + # Update the last save data. + config_data.save_config = new_save_config + config_vars.save.clear() + config_vars.latent_save.clear() + for section, variables in list(config_vars.now.items()): + config_vars.save.update({section: []}) + for variable in variables: + config_vars.save[section].append(variable.copy()) + for section, variables in list(config_vars.latent.items()): + config_vars.latent_save.update({section: []}) + for variable in variables: + config_vars.latent_save[section].append(variable.copy()) + config_sections.save.clear() + config_sections.latent_save.clear() + for section, data in list(config_sections.now.items()): + config_sections.save.update({section: data.copy()}) + for section, data in list(config_sections.latent.items()): + config_sections.latent_save.update({section: data.copy()}) + self.data.saved_config_names = set(self.data.config.keys()) + # Update open pages. + self._get_pagelist() + for page in self.pagelist: + page.refresh_widget_status() + # Update everything else. + self.updater.update_all() + return save_ok + + def output_config_objects(self, only_config_name=None): + """Return a dict of config name - object pairs from this session.""" + if only_config_name is None: + config_names = list(self.data.config.keys()) + else: + config_names = [only_config_name] + return_dict = {} + for config_name in config_names: + config = self.data.dump_to_internal_config(config_name) + return_dict.update({config_name: config}) + return return_dict + + # ----------------- Secondary Menu/Dialog handling functions -------------- + + def apply_macro_transform(self, *args, **kwargs): + """Placeholder for updater module function.""" + self.updater.apply_macro_transform(*args, **kwargs) + + def apply_macro_validation(self, *args, **kwargs): + """Placeholder for updater module function.""" + self.updater.apply_macro_validation(*args, **kwargs) + + def _add_config(self, config_name, meta=None): + """Add a configuration, optionally with META=TYPE=meta.""" + config_short_name = config_name.split("/")[-1] + root = os.path.join( + self.data.top_level_directory, metomi.rose.SUB_CONFIGS_DIR + ) + new_path = os.path.join( + root, config_short_name, metomi.rose.SUB_CONFIG_NAME + ) + new_config = metomi.rose.config.ConfigNode() + if meta is not None: + new_config.set( + [ + metomi.rose.CONFIG_SECT_TOP, + metomi.rose.CONFIG_OPT_META_TYPE, + ], + meta, + ) + try: + os.mkdir(os.path.dirname(new_path)) + metomi.rose.config.dump(new_config, new_path) + except (OSError, IOError) as exc: + text = metomi.rose.config_editor.ERROR_CONFIG_CREATE.format( + new_path, type(exc), str(exc) + ) + title = metomi.rose.config_editor.ERROR_CONFIG_CREATE_TITLE + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + return False + self.data.load_config( + os.path.dirname(new_path), + reload_tree_on=True, + skip_load_event=True, + ) + stack_item = metomi.rose.config_editor.stack.StackItem( + config_name, + metomi.rose.config_editor.STACK_ACTION_ADDED, + metomi.rose.variable.Variable("", "", {}), + self._remove_config, + (config_name, meta), + ) + self.undo_stack.append(stack_item) + while self.redo_stack: + self.redo_stack.pop() + self.view_page(config_name) + self.updater.update_namespace(config_name) + + def _remove_config(self, config_name, meta=None): + """Remove a configuration, optionally caching a meta id.""" + config_data = self.data.config[config_name] + dirpath = config_data.directory + nses = self.data.helper.get_all_namespaces(config_name) + nses.remove(config_name) + self._get_pagelist() + for page in self.pagelist: + name = self.util.split_full_ns(self.data, page.namespace)[0] + if name == config_name: + if name in self.notebook.get_page_ids(): + self.notebook.delete_by_id(name) + else: + tab_nses = [ + w.get_child().namespace for w in self.tab_windows + ] + page_window = self.tab_windows[tab_nses.index(name)] + page_window.destroy() + self.group_ops.remove_sections( + config_name, list(config_data.sections.now.keys()) + ) + if dirpath is not None: + try: + shutil.rmtree(dirpath) + except (shutil.Error, OSError, IOError) as exc: + text = metomi.rose.config_editor.ERROR_CONFIG_DELETE.format( + dirpath, type(exc), str(exc) + ) + title = metomi.rose.config_editor.ERROR_CONFIG_CREATE_TITLE + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + return False + self.data.config.pop(config_name) + self.reload_namespace_tree() + stack_item = metomi.rose.config_editor.stack.StackItem( + config_name, + metomi.rose.config_editor.STACK_ACTION_REMOVED, + metomi.rose.variable.Variable("", "", {}), + self._add_config, + (config_name, meta), + ) + self.undo_stack.append(stack_item) + while self.redo_stack: + self.redo_stack.pop() + + def _get_menu_widget(self, suffix): + """Return the menu widget whose ui address ends with suffix.""" + for address in self.menu_widgets: + if address.endswith(suffix): + return self.menu_widgets[address] + return None + + def _has_preview_apps(self): + """Return whether any configurations are currently just previews.""" + for item in list(self.data.config.keys()): + if self.data.config[item].is_preview: + return True + else: + return False + + def update_bar_widgets(self): + """Update bar functionality like Undo and Redo.""" + if not hasattr(self, "toolbar"): + return False + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_UNDO, len(self.undo_stack) > 0 + ) + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_REDO, len(self.redo_stack) > 0 + ) + self._get_menu_widget("/Undo").set_sensitive(len(self.undo_stack) > 0) + self._get_menu_widget("/Redo").set_sensitive(len(self.redo_stack) > 0) + self._get_menu_widget("/Find Next").set_sensitive( + len(self.find_hist["ids"]) > 0 + ) + self._get_menu_widget("/Load All Apps").set_sensitive( + self._has_preview_apps() + ) + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_LOAD_APPS, + self._has_preview_apps(), + ) + if not hasattr(self, "nav_panel"): + return False + changes, errors = self.nav_panel.get_change_error_totals() + self.status_bar.set_num_errors(errors) + self._get_menu_widget("/Autofix").set_sensitive(bool(errors)) + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_TRANSFORM, bool(errors) + ) + self._update_changed_sensitivity(is_changed=bool(changes)) + + def update_status_text(self, *args, **kwargs): + """Update the message displayed in the status bar.""" + if hasattr(self, "status_bar"): + self.status_bar.set_message(*args, **kwargs) + + def _update_changed_sensitivity(self, is_changed=False): + """Alter sensitivity of 'unsaved changes' related widgets.""" + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_SAVE, is_changed + ) + self.toolbar.set_widget_sensitive( + metomi.rose.config_editor.TOOLBAR_CHECK_AND_SAVE, is_changed + ) + self._get_menu_widget("/Save").set_sensitive(is_changed) + self._get_menu_widget("/Check and save").set_sensitive(is_changed) + self._get_menu_widget("/Graph").set_sensitive(not is_changed) + + def _refresh_metadata_if_on(self, config_name=None): + """Reload any metadata, if present - otherwise do nothing.""" + if not self.metadata_off: + self.refresh_metadata(only_this_config=config_name) + + def refresh_metadata(self, metadata_off=False, only_this_config=None): + """Switch metadata on/off and reloads namespaces.""" + self.metadata_off = metadata_off + if hasattr(self, "menubar"): + self._get_menu_widget("/Reload metadata").set_sensitive( + not self.metadata_off + ) + if only_this_config is None: + configs = list(self.data.config.keys()) + else: + configs = [only_this_config] + for config_name in configs: + if self.data.config[config_name].is_preview: + continue + self.data.clear_meta_lookups(config_name) + config = self.data.dump_to_internal_config(config_name) + config_data = self.data.config[config_name] + config_data.config = config + directory = config_data.directory + del config_data.macros + meta_config = config_data.meta + if metadata_off: + meta_config_tree = self.data.load_meta_config_tree( + config_type=config_data.config_type, + opt_meta_paths=self.data.opt_meta_paths, + ) + meta_config = meta_config_tree.node + meta_files = self.data.load_meta_files(meta_config_tree) + macros = [] + else: + meta_config_tree = self.data.load_meta_config_tree( + config, + directory, + config_type=config_data.config_type, + opt_meta_paths=self.data.opt_meta_paths, + ) + meta_config = meta_config_tree.node + meta_files = self.data.load_meta_files(meta_config_tree) + macro_module_prefix = self.data.helper.get_macro_module_prefix( + config_name + ) + macros = metomi.rose.macro.load_meta_macro_modules( + meta_files, module_prefix=macro_module_prefix + ) + config_data.meta = meta_config + self.data.load_builtin_macros(config_name) + self.data.load_file_metadata(config_name) + self.data.filter_meta_config(config_name) + # Load section and variable data into the object. + sects, l_sects = self.data.load_sections_from_config(config_name) + s_sects, s_l_sects = self.data.load_sections_from_config( + config_name, save=True + ) + config_data.sections = metomi.rose.config_editor.data.SectData( + sects, l_sects, s_sects, s_l_sects + ) + var, l_var = self.data.load_vars_from_config(config_name) + s_var, s_l_var = self.data.load_vars_from_config( + config_name, save=True + ) + config_data.vars = metomi.rose.config_editor.data.VarData( + var, l_var, s_var, s_l_var + ) + config_data.meta_files = meta_files + config_data.macros = macros + self.data.load_node_namespaces(config_name) + self.data.load_node_namespaces(config_name, from_saved=True) + self.data.load_ignored_data(config_name) + self.data.load_metadata_for_namespaces(config_name) + self.reload_namespace_tree() + if self.is_pluggable: + self.updater.update_all() + if hasattr(self, "menubar"): + self.main_handle.load_macro_menu(self.menubar) + namespaces_updated = [] + for config_name in configs: + config_data = self.data.config[config_name] + for variable in config_data.vars.get_all(skip_latent=True): + ns = variable.metadata.get("full_ns") + if ns not in namespaces_updated: + self.updater.update_tree_status(ns, icon_type="changed") + namespaces_updated.append(ns) + self._get_pagelist() + current_page, current_id = self._get_current_page_and_id() + current_namespace = None + if current_page is not None: + current_namespace = current_page.namespace + + # Generate replacements for existing pages. + for page in self.pagelist: + namespace = page.namespace + config_name = self.util.split_full_ns(self.data, namespace)[0] + if config_name not in configs: + continue + data, missing_data = self.data.helper.get_data_for_namespace( + namespace + ) + if len(data + missing_data) > 0: + new_page = self.make_page(namespace) + if new_page is None: + continue + if page in [w.get_child() for w in self.tab_windows]: + # Insert a new page into the old window. + tab_pages = [w.get_child() for w in self.tab_windows] + old_window = self.tab_windows[tab_pages.index(page)] + old_window.remove(page) + self._handle_detach_request(new_page, old_window) + elif hasattr(self, "notebook"): + # Replace a notebook page. + index = self.notebook.get_page_ids().index(namespace) + self.notebook.remove_page(index) + self.notebook.insert_page( + new_page, new_page.labelwidget, index + ) + else: + # Replace an orphan page + parent = page.get_parent() + if parent is not None: + parent.remove(page) + parent.pack_start(new_page, True, True, 0) + self.orphan_pages.remove(page) + self.orphan_pages.append(new_page) + else: + self.kill_page(page.namespace) + + # Preserve the old current page view, if possible. + if current_namespace is not None: + config_name = self.util.split_full_ns( + self.data, current_namespace + )[0] + self._get_pagelist() + page_namespaces = [page.namespace for page in self.pagelist] + if config_name in configs: + if current_namespace in page_namespaces: + self.view_page(current_namespace, current_id) + + def load_custom_metadata(self): + # open metadata dialog, use list() to pass by value + paths = self.mainwindow.launch_metadata_manager( + list(self.data.opt_meta_paths) + ) + if paths is not None: + # if form submitted + self.data.opt_meta_paths = paths + self.refresh_metadata() + + # ----------------- Data-intensive menu functions / utilities ------------- + + def _launch_find(self, *args): + """Get the find expression from a dialog.""" + if not self.find_entry.is_focus(): + self.find_entry.grab_focus() + expression = self.find_entry.get_text() + start_page = self._get_current_page() + if expression is not None and expression != "": + page, var_id = self.perform_find(expression, start_page) + if page is None: + text = metomi.rose.config_editor.WARNING_NOT_FOUND + self.find_entry.set_icon_from_stock( + 0, Gtk.STOCK_DIALOG_WARNING + ) + self.find_entry.set_icon_tooltip_text(0, text) + else: + if var_id is not None: + self.reporter.report( + metomi.rose.config_editor.EVENT_FOUND_ID.format(var_id) + ) + self._clear_find() + + def _clear_find(self, *args): + """Clear any warning icons from the find entry.""" + self.find_entry.set_icon_from_stock(0, None) + + def perform_find(self, expression, start_page=None): + """Drive the finding of the regex 'expression' within the data.""" + if expression == "": + return None, None + page_id, var_id = self.get_found_page_and_id(expression, start_page) + return self.view_page(page_id, var_id), var_id + + def perform_find_by_ns_id(self, namespace, setting_id): + """Drive find by id.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + self.perform_find_by_id(config_name, setting_id) + + def perform_find_by_id(self, config_name, setting_id): + """Drive the finding of a setting id within the data.""" + section, option = self.util.get_section_option_from_id(setting_id) + if option is None: + page_id = self.data.helper.get_default_section_namespace( + section, config_name + ) + self.view_page(page_id) + else: + var = self.data.helper.get_variable_by_id(setting_id, config_name) + if var is None: + var = self.data.helper.get_variable_by_id( + setting_id, config_name, latent=True + ) + if var is not None: + page_id = var.metadata["full_ns"] + self.view_page(page_id, setting_id) + + def get_found_page_and_id(self, expression, start_page): + """Using regex expression, return a matching page and variable.""" + try: + reg_find = re.compile(expression).search + except sre_constants.error as exc: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.ERROR_NOT_REGEX.format( + expression, str(exc) + ), + metomi.rose.config_editor.ERROR_BAD_FIND, + ) + return None, None + if self.find_hist["regex"] != expression: + self.find_hist["ids"] = [] + self.find_hist["regex"] = expression + if start_page is None: + ns_cmp = lambda x, y: 0 + name_cmp = lambda x, y: 0 + else: + current_ns = start_page.namespace + current_name = self.util.split_full_ns(self.data, current_ns)[0] + ns_cmp = lambda x, y: (y == current_ns) - (x == current_ns) + name_cmp = lambda x, y: (y == current_name) - (x == current_name) + config_keys = sorted(list(self.data.config.keys())) + config_keys.sort(key=cmp_to_key(name_cmp)) + for config_name in config_keys: + config_data = self.data.config[config_name] + search_vars = config_data.vars.get_all( + skip_latent=not self.page_var_show_modes["latent"] + ) + found_ns_vars = {} + for variable in search_vars: + var_id = variable.metadata.get("id") + ns = variable.metadata.get("full_ns") + if ( + metomi.rose.META_PROP_TITLE in variable.metadata + and reg_find( + variable.metadata[metomi.rose.META_PROP_TITLE] + ) + ): + found_ns_vars.setdefault(ns, []) + found_ns_vars[ns].append(variable) + continue + if reg_find(variable.name) or reg_find(variable.value): + found_ns_vars.setdefault(ns, []) + found_ns_vars[ns].append(variable) + ns_list = sorted(list(found_ns_vars.keys())) + ns_list.sort(key=cmp_to_key(ns_cmp)) + for ns in ns_list: + variables = found_ns_vars[ns] + variables.sort(key=lambda x: x.metadata["id"]) + for variable in variables: + var_id = variable.metadata["id"] + if (config_name, var_id) not in self.find_hist["ids"]: + if ( + not self.page_var_show_modes["fixed"] + and len(variable.metadata.get("values", [])) == 1 + ): + continue + if ( + not self.page_var_show_modes["ignored"] + and variable.ignored_reason + ): + continue + self.find_hist["ids"].append((config_name, var_id)) + return ns, var_id + if self.find_hist["ids"]: + config_name, var_id = self.find_hist["ids"][0] + config_data = self.data.config[config_name] + var = self.data.helper.get_variable_by_id(var_id, config_name) + if var is None: + var = self.data.helper.get_variable_by_id( + var_id, config_name, latent=True + ) + if var is not None: + self.find_hist["ids"] = [self.find_hist["ids"][0]] + return var.metadata["full_ns"], var_id + return None, None + + def check_cannot_enable_setting(self, config_name, setting_id): + """Check if the setting is involved in the trigger mechanism.""" + return setting_id in self.data.trigger[config_name].get_all_ids() + + def perform_undo(self, redo_mode_on=False): + """Change focus to the correct page and call an undo or redo. + + It grabs the relevant page and widget focus and calls the + correct 'undo_func' StackItem attribute function. + It then regenerates the affected container and sets the focus to + the variable that was last affected by the undo. + + """ + if redo_mode_on: + stack = self.redo_stack + else: + stack = self.undo_stack + if not stack: + return False + if self.performing_undo: + # Prevent multiple calls from existing concurrently. + return False + else: + self.performing_undo = True + self._get_pagelist() + do_list = [stack[-1]] + # We should undo/redo all same-grouped items together. + for stack_item in reversed(stack[:-1]): + if ( + stack_item.group is None + or stack_item.group != do_list[0].group + ): + break + do_list.append(stack_item) + group = do_list[0].group + is_group = len(do_list) > 1 + stack_info = [] + namespace_id_map = {} + event_text = metomi.rose.config_editor.EVENT_UNDO + if redo_mode_on: + event_text = metomi.rose.config_editor.EVENT_REDO + for stack_item in do_list: + action = stack_item.action + node = stack_item.node + node_id = None + try: + node_id = node.metadata["id"] + except (AttributeError, KeyError): + pass + # We need to handle namespace and metadata changes + if node_id is None: + # Not a variable or section + namespace = stack_item.page_label + node_is_section = False + else: + # A variable or section + opt = self.util.get_section_option_from_id(node_id)[1] + node_is_section = opt is None + namespace = node.metadata.get("full_ns") + if namespace is None: + namespace = stack_item.page_label + config_name = self.util.split_full_ns(self.data, namespace)[0] + node.process_metadata( + self.data.helper.get_metadata_for_config_id( + node_id, config_name + ) + ) + self.data.load_ns_for_node(node, config_name) + namespace = node.metadata.get("full_ns") + if ( + not is_group + and self.nav_controller.is_ns_in_tree(namespace) + and not node_is_section + ): + page = self.view_page(namespace, node_id) + redo_items = [x for x in self.redo_stack] + if stack_item.undo_args: + args = list(stack_item.undo_args) + for i, arg_item in enumerate(args): + if arg_item == stack_item.page_label: + # Then it is a namespace argument & should be changed. + args[i] = namespace + stack_item.undo_func(*args) + else: + stack_item.undo_func() + del self.redo_stack[:] + self.redo_stack.extend(redo_items) + just_done_item = self.undo_stack[-1] + just_done_item.group = group + del self.undo_stack[-1] + del stack[-1] + if redo_mode_on: + self.undo_stack.append(just_done_item) + else: + self.redo_stack.append(just_done_item) + if not self.nav_controller.is_ns_in_tree(namespace): + self.reload_namespace_tree() + page = None + if is_group: + # Store namespaces and ids for later updating. + stack_info.extend([namespace, stack_item.page_label]) + namespace_id_map.setdefault(namespace, []) + namespace_id_map[namespace].append(node_id) + if namespace != stack_item.page_label: + namespace_id_map.setdefault(stack_item.page_label, []) + namespace_id_map[stack_item.page_label].append(node_id) + elif self.nav_controller.is_ns_in_tree(namespace): + if not node_is_section: + # Section operations should not require pages. + page = self.view_page(namespace, node_id) + self.updater.sync_page_var_lists(page) + page.sort_data() + page.refresh(node_id) + page.update_ignored() + page.update_info() + page.set_main_focus(node_id) + self.set_current_page_indicator(page.namespace) + if namespace != stack_item.page_label: + # Make sure the right status update is made. + self.updater.update_status(page) + self.update_bar_widgets() + self.updater.update_stack_viewer_if_open() + if not is_group: + if namespace is not None: + self.updater.focus_sub_page_if_open(namespace, node_id) + if node_id is None: + title = stack_item.name + else: + title = node_id + id_text = ( + metomi.rose.config_editor.EVENT_UNDO_ACTION_ID.format( + action, title + ) + ) + self.reporter.report(event_text.format(id_text)) + if is_group: + group_name = do_list[0].group.split("-")[0] + self.reporter.report(event_text.format(group_name)) + namespace = None + for namespace in set(stack_info): + self.reload_namespace_tree(namespace) + # Use the last node_id for a sub page focus (if any). + if namespace: + focus_id = namespace_id_map[namespace][-1] + self.updater.focus_sub_page_if_open(namespace, focus_id) + self.performing_undo = False + return True + + +# ----------------------- System functions ----------------------------------- + + +def spawn_window( + config_directory_path=None, + debug_mode=False, + load_all_apps=False, + load_no_apps=False, + metadata_off=False, + initial_namespaces=None, + opt_meta_paths=None, + no_warn=None, +): + """Create a window and load the configuration into it. Run Gtk.""" + if opt_meta_paths is None: + opt_meta_paths = [] + if not debug_mode: + warnings.filterwarnings("ignore") + resourcer = metomi.rose.resource.ResourceLocator(paths=sys.path) + metomi.rose.gtk.util.rc_setup( + str(resourcer.locate("etc/rose-config-edit/.gtkrc-2.0")) + ) + metomi.rose.gtk.util.setup_stock_icons() + logo = resourcer.locate("etc/images/rose-splash-logo.png") + if metomi.rose.config_editor.ICON_PATH_SCHEDULER is None: + gcontrol_icon = None + else: + try: + gcontrol_icon = resourcer.locate( + metomi.rose.config_editor.ICON_PATH_SCHEDULER + ) + except metomi.rose.resource.ResourceError: + gcontrol_icon = None + metomi.rose.gtk.util.setup_scheduler_icon(gcontrol_icon) + number_of_events = ( + get_number_of_configs(config_directory_path) + * metomi.rose.config_editor.LOAD_NUMBER_OF_EVENTS + + 2 + ) + if config_directory_path is None: + title = metomi.rose.config_editor.UNTITLED_NAME + else: + title = config_directory_path.split("/")[-1] + splash_screen = metomi.rose.gtk.splash.SplashScreenProcess( + logo, title, number_of_events + ) + try: + ctrl = MainController( + config_directory_path, + load_updater=splash_screen, + load_all_apps=load_all_apps, + load_no_apps=load_no_apps, + metadata_off=metadata_off, + opt_meta_paths=opt_meta_paths, + no_warn=no_warn, + ) + except BaseException: + splash_screen.stop() + raise + + # open up any initial_namespaces the user has provided us with + if initial_namespaces: + # if the namespace ends with a / remove it + for i in range(len(initial_namespaces)): + if ( + len(initial_namespaces[i]) > 1 + and initial_namespaces[i][-1] == "/" + ): + initial_namespaces[i] = initial_namespaces[i][0:-1] + + # for each partial namespace get the full namespace + full_namespaces = [] + for namespace in initial_namespaces: + exp = re.compile(r"(.*%s?[^\/]+)" % (re.escape(namespace),)) + for ns in sorted(sorted(ctrl.data.namespace_meta_lookup), key=len): + match = exp.search(ns) + if match: + full_namespaces.append(match.groups()[0]) + break + + # open each namespace in a new tab + for namespace in full_namespaces: + # if the namespace begins with a / remove it + if namespace[0] == "/": + namespace = namespace[1:] + # open namespace + try: + ctrl.view_page(namespace) + except Exception: + print("could not open " + namespace, file=sys.stderr) + # expand namespace in nav_panel + path = ctrl.nav_panel.get_path_from_names(namespace.split("/")) + if path: + ctrl.nav_panel.tree.expand_to_path(path) + + Gtk.Settings.get_default().set_long_property( + "gtk-button-images", True, "main" + ) + Gtk.Settings.get_default().set_long_property( + "gtk-menu-images", True, "main" + ) + splash_screen.stop() + Gtk.main() + + +def spawn_subprocess_window(config_directory_path=None): + """Launch a subprocess for a new config editor. Is it safe?""" + if config_directory_path is None: + os.system(metomi.rose.config_editor.LAUNCH_COMMAND + " --new &") + return + elif not os.path.isdir(str(config_directory_path)): + return + os.system( + metomi.rose.config_editor.LAUNCH_COMMAND_CONFIG + + config_directory_path + + " &" + ) + + +def get_number_of_configs(config_directory_path=None): + """Return the number of configurations that will be loaded.""" + number_to_load = 0 + if config_directory_path is not None: + for listing in set(os.listdir(config_directory_path)): + if listing in metomi.rose.CONFIG_NAMES: + number_to_load += 1 + app_dir = os.path.join( + config_directory_path, metomi.rose.SUB_CONFIGS_DIR + ) + if os.path.exists(app_dir): + for entry in os.listdir(app_dir): + if os.path.isdir( + os.path.join(app_dir, entry) + ) and not entry.startswith("."): + number_to_load += 1 + return number_to_load + + +def main(): + """Launch from the command line.""" + sys.path.append(os.getenv("ROSE_HOME")) + opt_parser = metomi.rose.opt_parse.RoseOptionParser() + opt_parser.add_my_options( + "conf_dir", + "meta_path", + "new_mode", + "load_no_apps", + "load_all_apps", + "no_metadata", + "no_warn", + ) + opts, args = opt_parser.parse_args() + metomi.rose.macro.add_meta_paths() + opt_meta_paths = [] + if opts.meta_path: + for meta_path in opts.meta_path: + for path in meta_path.split(os.pathsep): + opt_meta_paths.append( + os.path.abspath( + os.path.expandvars(os.path.expanduser(path)) + ) + ) + if opts.conf_dir: + os.chdir(opts.conf_dir) + path = os.getcwd() + name_set = set([metomi.rose.SUB_CONFIG_NAME, metomi.rose.TOP_CONFIG_NAME]) + while True: + if set(os.listdir(path)) & name_set: + break + path = os.path.dirname(path) + if path == os.path.dirname(path): + # We don't support suites located at the root! + break + if path != os.getcwd() and path != os.path.dirname(path): + os.chdir(path) + cwd = os.getcwd() + if opts.new_mode: + cwd = None + metomi.rose.gtk.dialog.set_exception_hook_dialog(keep_alive=True) + + screen = Gdk.Screen.get_default() + provider = Gtk.CssProvider() + style_context = Gtk.StyleContext() + style_context.add_provider_for_screen( + screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + css_path = locator.locate("etc/rose-config-edit/style.css") + provider.load_from_path(str(css_path)) + + if opts.profile_mode: + handle = tempfile.NamedTemporaryFile() + cProfile.runctx( + """spawn_window(cwd, debug_mode=opts.debug_mode, + load_all_apps=opts.load_all_apps, + load_no_apps=opts.load_no_apps, + metadata_off=opts.no_metadata, + initial_namespaces=args, + opt_meta_paths=opt_meta_paths, + no_warn=opts.no_warn) + """, + globals(), + locals(), + handle.name, + ) + pstat = pstats.Stats(handle.name) + pstat.strip_dirs().sort_stats("cumulative").print_stats() + handle.close() + else: + spawn_window( + cwd, + debug_mode=opts.debug_mode, + load_all_apps=opts.load_all_apps, + load_no_apps=opts.load_no_apps, + metadata_off=opts.no_metadata, + initial_namespaces=args, + opt_meta_paths=opt_meta_paths, + no_warn=opts.no_warn, + ) + + +if __name__ == "__main__": + main() diff --git a/metomi/rose/config_editor/menu.py b/metomi/rose/config_editor/menu.py new file mode 100644 index 0000000000..c0d20d566a --- /dev/null +++ b/metomi/rose/config_editor/menu.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import ast +import inspect +import os +import shlex +import sys +import traceback + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.config_editor.upgrade_controller +import metomi.rose.external +import metomi.rose.gtk.dialog +import metomi.rose.macro +import metomi.rose.macros +import metomi.rose.popen + +from functools import cmp_to_key + + +class MenuBar(object): + """Generate the menu bar, using the GTK UIManager. + + Parses the settings in 'ui_config_string'. Connection of buttons is done + at a higher level. + + """ + + ui_config_string = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + action_details = [ + ("File", None, metomi.rose.config_editor.TOP_MENU_FILE), + ( + "Open...", + Gtk.STOCK_OPEN, + metomi.rose.config_editor.TOP_MENU_FILE_OPEN, + metomi.rose.config_editor.ACCEL_OPEN, + ), + ( + "Save", + Gtk.STOCK_SAVE, + metomi.rose.config_editor.TOP_MENU_FILE_SAVE, + metomi.rose.config_editor.ACCEL_SAVE, + ), + ( + "Check and save", + Gtk.STOCK_SPELL_CHECK, + metomi.rose.config_editor.TOP_MENU_FILE_CHECK_AND_SAVE, + ), + ( + "Load All Apps", + Gtk.STOCK_CDROM, + metomi.rose.config_editor.TOP_MENU_FILE_LOAD_APPS, + ), + ( + "Quit", + Gtk.STOCK_QUIT, + metomi.rose.config_editor.TOP_MENU_FILE_QUIT, + metomi.rose.config_editor.ACCEL_QUIT, + ), + ("Edit", None, metomi.rose.config_editor.TOP_MENU_EDIT), + ( + "Undo", + Gtk.STOCK_UNDO, + metomi.rose.config_editor.TOP_MENU_EDIT_UNDO, + metomi.rose.config_editor.ACCEL_UNDO, + ), + ( + "Redo", + Gtk.STOCK_REDO, + metomi.rose.config_editor.TOP_MENU_EDIT_REDO, + metomi.rose.config_editor.ACCEL_REDO, + ), + ( + "Stack", + Gtk.STOCK_INFO, + metomi.rose.config_editor.TOP_MENU_EDIT_STACK, + ), + ( + "Find", + Gtk.STOCK_FIND, + metomi.rose.config_editor.TOP_MENU_EDIT_FIND, + metomi.rose.config_editor.ACCEL_FIND, + ), + ( + "Find Next", + Gtk.STOCK_FIND, + metomi.rose.config_editor.TOP_MENU_EDIT_FIND_NEXT, + metomi.rose.config_editor.ACCEL_FIND_NEXT, + ), + ( + "Preferences", + Gtk.STOCK_PREFERENCES, + metomi.rose.config_editor.TOP_MENU_EDIT_PREFERENCES, + ), + ("View", None, metomi.rose.config_editor.TOP_MENU_VIEW), + ("Page", None, metomi.rose.config_editor.TOP_MENU_PAGE), + ( + "Add variable", + Gtk.STOCK_ADD, + metomi.rose.config_editor.TOP_MENU_PAGE_ADD, + ), + ( + "Revert", + Gtk.STOCK_REVERT_TO_SAVED, + metomi.rose.config_editor.TOP_MENU_PAGE_REVERT, + ), + ( + "Page Info", + Gtk.STOCK_INFO, + metomi.rose.config_editor.TOP_MENU_PAGE_INFO, + ), + ( + "Page Help", + Gtk.STOCK_HELP, + metomi.rose.config_editor.TOP_MENU_PAGE_HELP, + ), + ( + "Page Web Help", + Gtk.STOCK_HOME, + metomi.rose.config_editor.TOP_MENU_PAGE_WEB_HELP, + ), + ("Metadata", None, metomi.rose.config_editor.TOP_MENU_METADATA), + ( + "Reload metadata", + Gtk.STOCK_REFRESH, + metomi.rose.config_editor.TOP_MENU_METADATA_REFRESH, + metomi.rose.config_editor.ACCEL_METADATA_REFRESH, + ), + ( + "Load custom metadata", + Gtk.STOCK_DIRECTORY, + metomi.rose.config_editor.TOP_MENU_METADATA_LOAD, + ), + ( + "Prefs", + Gtk.STOCK_PREFERENCES, + metomi.rose.config_editor.TOP_MENU_METADATA_PREFERENCES, + ), + ( + "Upgrade", + Gtk.STOCK_GO_UP, + metomi.rose.config_editor.TOP_MENU_METADATA_UPGRADE, + ), + ( + "All V", + "dialog-question", + metomi.rose.config_editor.TOP_MENU_METADATA_MACRO_ALL_V, + ), + ( + "Autofix", + Gtk.STOCK_CONVERT, + metomi.rose.config_editor.TOP_MENU_METADATA_MACRO_AUTOFIX, + ), + ( + "Extra checks", + "dialog-question", + metomi.rose.config_editor.TOP_MENU_METADATA_CHECK, + ), + ( + "Graph", + Gtk.STOCK_SORT_ASCENDING, + metomi.rose.config_editor.TOP_MENU_METADATA_GRAPH, + ), + ("Tools", None, metomi.rose.config_editor.TOP_MENU_TOOLS), + ( + "Browser", + Gtk.STOCK_DIRECTORY, + metomi.rose.config_editor.TOP_MENU_TOOLS_BROWSER, + metomi.rose.config_editor.ACCEL_BROWSER, + ), + ( + "Terminal", + Gtk.STOCK_EXECUTE, + metomi.rose.config_editor.TOP_MENU_TOOLS_TERMINAL, + metomi.rose.config_editor.ACCEL_TERMINAL, + ), + ("Help", None, metomi.rose.config_editor.TOP_MENU_HELP), + ( + "Documentation", + Gtk.STOCK_HELP, + metomi.rose.config_editor.TOP_MENU_HELP_GUI, + metomi.rose.config_editor.ACCEL_HELP_GUI, + ), + ( + "About", + Gtk.STOCK_DIALOG_INFO, + metomi.rose.config_editor.TOP_MENU_HELP_ABOUT, + ), + ] + + toggle_action_details = [ + ( + "View latent vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_LATENT_VARS, + ), + ( + "View fixed vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_FIXED_VARS, + ), + ( + "View ignored vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_IGNORED_VARS, + ), + ( + "View user-ignored vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_USER_IGNORED_VARS, + ), + ( + "View without descriptions", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_WITHOUT_DESCRIPTIONS, + ), + ( + "View without help", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_WITHOUT_HELP, + ), + ( + "View without titles", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_WITHOUT_TITLES, + ), + ( + "View ignored pages", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_IGNORED_PAGES, + ), + ( + "View user-ignored pages", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_USER_IGNORED_PAGES, + ), + ( + "View latent pages", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_LATENT_PAGES, + ), + ( + "Flag opt config vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_FLAG_OPT_CONF_VARS, + ), + ( + "Flag optional vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_FLAG_OPTIONAL_VARS, + ), + ( + "Flag no-metadata vars", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_FLAG_NO_METADATA_VARS, + ), + ( + "View status bar", + None, + metomi.rose.config_editor.TOP_MENU_VIEW_STATUS_BAR, + ), + ( + "Switch off metadata", + None, + metomi.rose.config_editor.TOP_MENU_METADATA_SWITCH_OFF, + ), + ] + + def __init__(self): + self.uimanager = Gtk.UIManager() + self.actiongroup = Gtk.ActionGroup("MenuBar") + self.actiongroup.add_actions(self.action_details) + self.actiongroup.add_toggle_actions(self.toggle_action_details) + self.uimanager.insert_action_group(self.actiongroup) + self.uimanager.add_ui_from_string(self.ui_config_string) + self.macro_ids = [] + + def set_accelerators(self, accel_dict): + """Add the keyboard accelerators.""" + self.accelerators = Gtk.AccelGroup() + self.accelerators.lookup = {} # Unfortunately, this is necessary. + for key_press, accel_func in list(accel_dict.items()): + key, mod = Gtk.accelerator_parse(key_press) + self.accelerators.lookup[str(key) + str(mod)] = accel_func + self.accelerators.connect( + key, + mod, + Gtk.AccelFlags.VISIBLE, + lambda a, c, k, m: self.accelerators.lookup[str(k) + str(m)](), + ) + + def clear_macros(self): + """Reset menu to original configuration and clear macros.""" + for merge_id in self.macro_ids: + self.uimanager.remove_ui(merge_id) + self.macro_ids = [] + all_v_item = self.uimanager.get_widget("/TopMenuBar/Metadata/All V") + all_v_item.set_sensitive(False) + + def add_macro( + self, + config_name, + modulename, + classname, + methodname, + help_, + image_path, + run_macro, + ): + """Add a macro to the macro menu.""" + macro_address = "/TopMenuBar/Metadata" + self.uimanager.get_widget(macro_address).get_submenu() + if methodname == metomi.rose.macro.VALIDATE_METHOD: + all_v_item = self.uimanager.get_widget(macro_address + "/All V") + all_v_item.set_sensitive(True) + config_menu_name = config_name.replace("/", ":").replace("_", "__") + config_label_name = config_name.split("/")[-1].replace("_", "__") + label = ( + metomi.rose.config_editor.TOP_MENU_METADATA_MACRO_CONFIG.format( + config_label_name + ) + ) + config_address = macro_address + "/" + config_menu_name + config_item = self.uimanager.get_widget(config_address) + if config_item is None: + actiongroup = self.uimanager.get_action_groups()[0] + if actiongroup.get_action(config_menu_name) is None: + actiongroup.add_action( + Gtk.Action(config_menu_name, label, None, None) + ) + new_ui = """ + + + """.format( + config_menu_name + ) + self.macro_ids.append(self.uimanager.add_ui_from_string(new_ui)) + config_item = self.uimanager.get_widget(config_address) + if image_path is not None: + image = Gtk.Image.new_from_file(image_path) + config_item.set_image(image) + if config_item.get_submenu() is None: + config_item.set_submenu(Gtk.Menu()) + macro_fullname = ".".join([modulename, classname, methodname]) + macro_fullname = macro_fullname.replace("__", "_") + if methodname == metomi.rose.macro.VALIDATE_METHOD: + stock_id = "dialog-question" + else: + stock_id = Gtk.STOCK_CONVERT + macro_item_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=6 + ) + macro_item_icon = Gtk.Image.new_from_icon_name( + stock_id, Gtk.IconSize.MENU + ) + macro_item_label = Gtk.Label(label=macro_fullname) + macro_item = Gtk.MenuItem() + Gtk.Container.add(macro_item_box, macro_item_icon) + Gtk.Container.add(macro_item_box, macro_item_label) + Gtk.Container.add(macro_item, macro_item_box) + macro_item.set_tooltip_text(help_) + context = Gtk.Widget.get_style_context(macro_item) + Gtk.StyleContext.add_class(context, "macro-item") + macro_item.show_all() + macro_item._run_data = [config_name, modulename, classname, methodname] + macro_item.connect("activate", lambda i: run_macro(*i._run_data)) + config_item.get_submenu().append(macro_item) + if methodname == metomi.rose.macro.VALIDATE_METHOD: + for item in config_item.get_submenu().get_children(): + if hasattr(item, "_rose_all_validators"): + return False + all_item_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=6 + ) + all_item_icon = Gtk.Image.new_from_icon_name( + "dialog-question", Gtk.IconSize.MENU + ) + all_item_label = Gtk.Label( + label=metomi.rose.config_editor.MACRO_MENU_ALL_VALIDATORS + ) + all_item = Gtk.MenuItem() + Gtk.Container.add(all_item_box, all_item_icon) + Gtk.Container.add(all_item_box, all_item_label) + Gtk.Container.add(all_item, all_item_box) + all_item._rose_all_validators = True + all_item.set_tooltip_text( + metomi.rose.config_editor.MACRO_MENU_ALL_VALIDATORS_TIP + ) + all_item.show_all() + all_item._run_data = [config_name, None, None, methodname] + all_item.connect("activate", lambda i: run_macro(*i._run_data)) + config_item.get_submenu().prepend(all_item) + + +class MainMenuHandler(object): + """Handles signals from the main menu and tool bar.""" + + def __init__( + self, + data, + util, + reporter, + mainwindow, + undo_stack, + redo_stack, + undo_func, + update_config_func, + apply_macro_transform_func, + apply_macro_validation_func, + group_ops_inst, + section_ops_inst, + variable_ops_inst, + find_ns_id_func, + ): + self.data = data + self.util = util + self.reporter = reporter + self.mainwindow = mainwindow + self.undo_stack = undo_stack + self.redo_stack = redo_stack + self.perform_undo = undo_func + self.update_config = update_config_func + self.apply_macro_transform = apply_macro_transform_func + self.apply_macro_validation = apply_macro_validation_func + self.group_ops = group_ops_inst + self.sect_ops = section_ops_inst + self.var_ops = variable_ops_inst + self.find_ns_id_func = find_ns_id_func + self.bad_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR + ) + + def about_dialog(self, args): + self.mainwindow.launch_about_dialog() + + def get_orphan_container(self, page): + """Return a container with the page object inside.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.pack_start(page, expand=True, fill=True) + box.show() + return box + + def view_stack(self, args): + """Handle a View Stack request.""" + self.mainwindow.launch_view_stack( + self.undo_stack, self.redo_stack, self.perform_undo + ) + + def destroy(self, *args): + """Handle a destroy main program request.""" + for name in self.data.config: + if self.data.helper.get_config_has_unsaved_changes(name): + self.mainwindow.launch_exit_warning_dialog() + return True + try: + Gtk.main_quit() + except RuntimeError: + # This can occur before Gtk.main() is called, during the load. + sys.exit() + + def check_all_extra(self): + """Check fail-if, warn-if, and run all validator macros.""" + for config_name in self.data.config: + if not self.data.config[config_name].is_preview: + self.update_config(config_name) + num_errors = self.check_fail_rules(configs_updated=True) + num_errors += self.run_custom_macro( + method_name=metomi.rose.macro.VALIDATE_METHOD, configs_updated=True + ) + if num_errors: + text = metomi.rose.config_editor.EVENT_MACRO_VALIDATE_CHECK_ALL.format( # noqa: E501 + num_errors + ) + kind = self.reporter.KIND_ERR + else: + text = metomi.rose.config_editor.EVENT_MACRO_VALIDATE_CHECK_ALL_OK + kind = self.reporter.KIND_OUT + self.reporter.report(text, kind=kind) + + def check_fail_rules(self, configs_updated=False): + """Check the fail-if and warn-if conditions of the configurations.""" + if not configs_updated: + for config_name in self.data.config: + if not self.data.config[config_name].is_preview: + self.update_config(config_name) + macro = metomi.rose.macros.rule.FailureRuleChecker() + macro_fullname = "rule.FailureRuleChecker.validate" + error_count = 0 + for config_name in sorted(self.data.config.keys()): + config_data = self.data.config[config_name] + if config_data.is_preview: + continue + config = config_data.config + meta = config_data.meta + try: + return_value = macro.validate(config, meta) + if return_value: + error_count += len(return_value) + except Exception as exc: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + str(exc), + metomi.rose.config_editor.ERROR_RUN_MACRO_TITLE.format( + macro_fullname + ), + ) + continue + sorter = metomi.rose.config.sort_settings + to_id = lambda s: self.util.get_id_from_section_option( + s.section, s.option + ) + return_value.sort( + key=cmp_to_key(lambda x, y: sorter(to_id(x), to_id(y))) + ) + self.handle_macro_validation( + config_name, + macro_fullname, + config, + return_value, + no_display=(not return_value), + ) + if error_count > 0: + msg = ( + metomi.rose.config_editor.EVENT_MACRO_VALIDATE_RULE_PROBLEMS_FOUND # noqa: E501 + ) + info_text = msg.format(error_count) + kind = self.reporter.KIND_ERR + else: + msg = ( + metomi.rose.config_editor.EVENT_MACRO_VALIDATE_RULE_NO_PROBLEMS + ) + info_text = msg + kind = self.reporter.KIND_OUT + self.reporter.report(info_text, kind=kind) + return error_count + + def clear_page_menu(self, menubar, add_menuitem): + """Clear all page add variable items.""" + add_menuitem.remove_submenu() + + def load_page_menu(self, menubar, add_menuitem, current_page): + """Load the page add variable items, if any.""" + if current_page is None: + return False + add_var_menu = current_page.get_add_menu() + if add_var_menu is None or not add_var_menu.get_children(): + add_menuitem.set_sensitive(False) + return False + add_menuitem.set_sensitive(True) + add_menuitem.set_submenu(add_var_menu) + + def load_macro_menu(self, menubar): + """Refresh the menu dealing with custom macro launches.""" + menubar.clear_macros() + config_keys = sorted(list(self.data.config.keys())) + for config_name in config_keys: + image = self.data.helper.get_icon_path_for_config(config_name) + macros = self.data.config[config_name].macros + macro_tuples = metomi.rose.macro.get_macro_class_methods(macros) + macro_tuples.sort(key=lambda x: x[0]) + for macro_mod, macro_cls, macro_func, help_ in macro_tuples: + menubar.add_macro( + config_name, + macro_mod, + macro_cls, + macro_func, + help_, + image, + self.handle_run_custom_macro, + ) + + def inspect_custom_macro(self, macro_meth): + """Inspect a custom macro for kwargs and return any""" + arglist = inspect.getfullargspec(macro_meth).args + defaultlist = inspect.getfullargspec(macro_meth).defaults + optionals = {} + while defaultlist is not None and len(defaultlist) > 0: + if arglist[-1] not in ["self", "config", "meta_config"]: + optionals[arglist[-1]] = defaultlist[-1] + arglist = arglist[0:-1] + defaultlist = defaultlist[0:-1] + else: + break + return optionals + + def handle_graph(self): + """Handle a graph metadata request.""" + config_sect_dict = {} + for config_name in self.data.config: + config_data = self.data.config[config_name] + config_sect_dict[config_name] = list( + config_data.sections.now.keys() + ) + config_sect_dict[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + config_name, section = self.mainwindow.launch_graph_dialog( + config_sect_dict + ) + if config_name is None: + return False + if section is None: + allowed_sections = None + else: + allowed_sections = [section] + self.launch_graph(config_name, allowed_sections=allowed_sections) + + def check_entry_value( + self, entry_widget, dialog, entries, labels, optionals + ): + is_valid = True + for k, entry in list(entries.items()): + this_is_valid = True + try: + new_val = ast.literal_eval(entry.get_text()) + entry.modify_text(Gtk.StateType.NORMAL, None) + except (ValueError, EOFError, SyntaxError): + entry.modify_text(Gtk.StateType.NORMAL, self.bad_colour) + is_valid = False + this_is_valid = False + if not this_is_valid or new_val != optionals[k]: + lab = '{0}'.format(str(k) + ":") + labels[k].set_markup(lab) + else: + labels[k].set_text(str(k) + ":") + dialog.set_response_sensitive(Gtk.ResponseType.OK, is_valid) + return + + def handle_macro_entry_activate(self, entry_widget, dialog, entries): + for entry in list(entries.values()): + try: + ast.literal_eval(entry.get_text()) + except (ValueError, EOFError, SyntaxError): + break + else: + dialog.response(Gtk.ResponseType.OK) + + def override_macro_defaults(self, optionals, methname): + """Launch a dialog to handle capture of any override args to macro""" + if not optionals: + return {} + res = {} + # create the text input field + entries = {} + labels = {} + dialog = Gtk.MessageDialog( + None, + Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, + Gtk.MessageType.QUESTION, + Gtk.ButtonsType.OK_CANCEL, + None, + ) + dialog.set_markup("Specify overrides for macro arguments:") + dialog.set_title(methname) + table = Gtk.Table(len(list(optionals.items())), 2, False) + dialog.vbox.add(table) + for i in range(len(list(optionals.items()))): + key, value = list(optionals.items())[i] + label = Gtk.Label(label=str(key) + ":") + entry = Gtk.Entry() + if isinstance(value, str): + entry.set_text("'" + value + "'") + else: + entry.set_text(str(value)) + entry.connect( + "changed", + self.check_entry_value, + dialog, + entries, + labels, + optionals, + ) + entry.connect( + "activate", self.handle_macro_entry_activate, dialog, entries + ) + entries[key] = entry + labels[key] = label + table.attach(entry, 1, 2, i, i + 1) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start(label, False, True, 0) + table.attach(hbox, 0, 1, i, i + 1) + dialog.show_all() + response = dialog.run() + if ( + response == Gtk.ResponseType.CANCEL + or response == Gtk.ResponseType.CLOSE + ): + res = optionals + else: + res = {} + for key, box in list(entries.items()): + res[key] = ast.literal_eval(box.get_text()) + dialog.destroy() + return res + + def handle_run_custom_macro(self, *args, **kwargs): + """Wrap the method so that this returns False for GTK callbacks.""" + self.run_custom_macro(*args, **kwargs) + return False + + def run_custom_macro( + self, + config_name=None, + module_name=None, + class_name=None, + method_name=None, + configs_updated=False, + ): + """Run the custom macro method and launch a dialog.""" + old_pwd = os.getcwd() + macro_data = [] + if config_name is None: + configs = sorted(self.data.config.keys()) + else: + configs = [config_name] + for name in list(configs): + if self.data.config[name].is_preview: + configs.remove(name) + continue + if not configs_updated: + self.update_config(name) + if method_name is None: + method_names = [ + metomi.rose.macro.VALIDATE_METHOD, + metomi.rose.macro.TRANSFORM_METHOD, + ] + else: + method_names = [method_name] + if module_name is not None and config_name is not None: + config_mod_prefix = self.data.helper.get_macro_module_prefix( + config_name + ) + if not module_name.startswith(config_mod_prefix): + module_name = config_mod_prefix + module_name + for config_name in configs: + config_data = self.data.config[config_name] + if config_data.directory is not None: + os.chdir(config_data.directory) + for module in config_data.macros: + if module_name is not None and module.__name__ != module_name: + continue + for obj_name, obj in inspect.getmembers(module): + for method_name in method_names: + if ( + not hasattr(obj, method_name) + or obj_name.startswith("_") + or not issubclass(obj, metomi.rose.macro.MacroBase) + ): + continue + if class_name is not None and obj_name != class_name: + continue + macro_fullname = ".".join( + [module.__name__, obj_name, method_name] + ) + err_text = metomi.rose.config_editor.ERROR_RUN_MACRO_TITLE.format( # noqa: E501 + macro_fullname + ) + try: + macro_inst = obj() + except Exception as exc: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + str(exc), + err_text, + ) + continue + if hasattr(macro_inst, method_name): + macro_data.append( + ( + config_name, + macro_inst, + module.__name__, + obj_name, + method_name, + ) + ) + os.chdir(old_pwd) + if not macro_data: + return 0 + sorter = metomi.rose.config.sort_settings + to_id = lambda s: self.util.get_id_from_section_option( + s.section, s.option + ) + config_macro_errors = [] + config_macro_changes = [] + for config_name, macro_inst, modname, objname, methname in macro_data: + macro_fullname = ".".join([modname, objname, methname]) + macro_config = self.data.dump_to_internal_config(config_name) + config_data = self.data.config[config_name] + meta_config = config_data.meta + macro_method = getattr(macro_inst, methname) + optionals = self.inspect_custom_macro(macro_method) + if optionals: + res = self.override_macro_defaults(optionals, objname) + else: + res = {} + os.chdir(config_data.directory) + try: + return_value = macro_method(macro_config, meta_config, **res) + except Exception: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + "Error in custom macro:\n\n%s" % (traceback.format_exc()), + metomi.rose.config_editor.ERROR_RUN_MACRO_TITLE.format( + macro_fullname + ), + ) + continue + if methname == metomi.rose.macro.TRANSFORM_METHOD: + if ( + not isinstance(return_value, tuple) + or len(return_value) != 2 + or not isinstance( + return_value[0], metomi.rose.config.ConfigNode + ) + or not isinstance(return_value[1], list) + ): + self._handle_bad_macro_return(macro_fullname, return_value) + continue + integrity_exception = metomi.rose.macro.check_config_integrity( + return_value[0] + ) + if integrity_exception is not None: + self._handle_bad_macro_return( + macro_fullname, integrity_exception + ) + continue + macro_config, change_list = return_value + if not change_list: + continue + change_list.sort( + key=cmp_to_key(lambda x, y: sorter(to_id(x), to_id(y))) + ) + num_changes = len(change_list) + self.handle_macro_transforms( + config_name, macro_fullname, macro_config, change_list + ) + config_macro_changes.append( + (config_name, macro_fullname, num_changes) + ) + continue + elif methname == metomi.rose.macro.VALIDATE_METHOD: + if not isinstance(return_value, list): + self._handle_bad_macro_return(macro_fullname, return_value) + continue + if return_value: + return_value.sort( + key=cmp_to_key(lambda x, y: sorter(to_id(x), to_id(y))) + ) + config_macro_errors.append( + (config_name, macro_fullname, len(return_value)) + ) + self.handle_macro_validation( + config_name, macro_fullname, macro_config, return_value + ) + os.chdir(old_pwd) + if class_name is None: + # Construct a grouped report. + config_macro_errors.sort() + config_macro_changes.sort() + if metomi.rose.macro.VALIDATE_METHOD in method_names: + null_format = ( + metomi.rose.config_editor.EVENT_MACRO_VALIDATE_ALL_OK + ) + change_format = ( + metomi.rose.config_editor.EVENT_MACRO_VALIDATE_ALL + ) + num_issues = sum([e[2] for e in config_macro_errors]) + issue_confs = [e[0] for e in config_macro_errors if e[2]] + else: + null_format = ( + metomi.rose.config_editor.EVENT_MACRO_TRANSFORM_ALL_OK + ) + change_format = ( + metomi.rose.config_editor.EVENT_MACRO_TRANSFORM_ALL + ) + num_issues = sum([e[2] for e in config_macro_changes]) + issue_confs = [e[0] for e in config_macro_changes if e[2]] + issue_confs = sorted(set(issue_confs)) + if num_issues: + issue_conf_text = self._format_macro_config_names(issue_confs) + self.reporter.report( + change_format.format(issue_conf_text, num_issues), + kind=self.reporter.KIND_ERR, + ) + else: + all_conf_text = self._format_macro_config_names(configs) + self.reporter.report( + null_format.format(all_conf_text), + kind=self.reporter.KIND_OUT, + ) + num_errors = sum([e[2] for e in config_macro_errors]) + num_changes = sum([c[2] for c in config_macro_changes]) + return num_errors + num_changes + + def _format_macro_config_names(self, config_names): + if len(config_names) > 5: + return metomi.rose.config_editor.EVENT_MACRO_CONFIGS.format( + len(config_names) + ) + config_names = [c.lstrip("/") for c in config_names] + return ", ".join(config_names) + + def _handle_bad_macro_return(self, macro_fullname, info): + if isinstance(info, Exception): + text = metomi.rose.config_editor.ERROR_BAD_MACRO_EXCEPTION.format( + type(info).__name__, str(info) + ) + else: + text = metomi.rose.config_editor.ERROR_BAD_MACRO_RETURN.format( + info + ) + summary = metomi.rose.config_editor.ERROR_RUN_MACRO_TITLE.format( + macro_fullname + ) + self.reporter.report(summary, kind=self.reporter.KIND_ERR) + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, summary + ) + + def handle_macro_transforms( + self, + config_name, + macro_name, + macro_config, + change_list, + no_display=False, + triggers_ok=False, + ): + """Calculate needed changes and apply them if prompted to. + + At the moment trigger-ignore of variables and sections is + assumed to be the exclusive property of the Rose trigger + macro and is not allowed for any other macro. + + """ + if not change_list: + self._report_macro_transform(config_name, macro_name, 0) + return + macro_type = ".".join(macro_name.split(".")[:-1]) + var_changes = [] + sect_changes = [] + for item in list(change_list): + if item.option is None: + sect_changes.append(item) + else: + var_changes.append(item) + search = lambda i: self.find_ns_id_func(config_name, i) + if not no_display: + proceed_ok = self.mainwindow.launch_macro_changes_dialog( + config_name, macro_type, change_list, search_func=search + ) + if not proceed_ok: + self._report_macro_transform(config_name, macro_name, 0) + return 0 + config_diff = macro_config - self.data.config[config_name].config + changed_ids = self.group_ops.apply_diff( + config_name, + config_diff, + origin_name=macro_type, + triggers_ok=triggers_ok, + ) + self.apply_macro_transform(config_name, changed_ids, skip_update=True) + self._report_macro_transform(config_name, macro_name, len(change_list)) + return len(change_list) + + def _report_macro_transform(self, config_name, macro_name, num_changes): + name = config_name.lstrip("/") + if macro_name.endswith(metomi.rose.macro.TRANSFORM_METHOD): + macro = macro_name.split(".")[-2] + else: + macro = macro_name.split(".")[-1] + kind = self.reporter.KIND_OUT + if num_changes: + info_text = metomi.rose.config_editor.EVENT_MACRO_TRANSFORM.format( + name, macro, num_changes + ) + else: + info_text = ( + metomi.rose.config_editor.EVENT_MACRO_TRANSFORM_OK.format( + name, macro + ) + ) + self.reporter.report(info_text, kind=kind) + + def handle_macro_validation( + self, + config_name, + macro_name, + macro_config, + problem_list, + no_display=False, + ): + """Apply errors and give information to the user.""" + macro_type = ".".join(macro_name.split(".")[:-1]) + self.apply_macro_validation(config_name, macro_type, problem_list) + search = lambda i: self.find_ns_id_func(config_name, i) + self._report_macro_validation( + config_name, macro_name, len(problem_list) + ) + if not no_display: + self.mainwindow.launch_macro_changes_dialog( + config_name, + macro_type, + problem_list, + mode="validate", + search_func=search, + ) + + def _report_macro_validation(self, config_name, macro_name, num_errors): + name = config_name.lstrip("/") + if macro_name.endswith(metomi.rose.macro.VALIDATE_METHOD): + macro = macro_name.split(".")[-2] + else: + macro = macro_name.split(".")[-1] + if num_errors: + info_text = metomi.rose.config_editor.EVENT_MACRO_VALIDATE.format( + name, macro, num_errors + ) + kind = self.reporter.KIND_ERR + else: + info_text = ( + metomi.rose.config_editor.EVENT_MACRO_VALIDATE_OK.format( + name, macro + ) + ) + kind = self.reporter.KIND_OUT + self.reporter.report(info_text, kind=kind) + + def handle_upgrade(self, only_this_config_name=None): + """Run the upgrade manager for this suite.""" + config_dict = {} + for config_name in self.data.config: + config_data = self.data.config[config_name] + if config_data.is_preview: + continue + self.update_config(config_name) + if ( + only_this_config_name is None + or config_name == only_this_config_name + ): + config_dict[config_name] = { + "config": config_data.config, + "directory": config_data.directory, + } + metomi.rose.config_editor.upgrade_controller.UpgradeController( + config_dict, + self.handle_macro_transforms, + parent_window=self.mainwindow.window, + upgrade_inspector=self.override_macro_defaults, + ) + + def help(self, *args): + """Handle a GUI help request.""" + self.mainwindow.launch_help_dialog() + + def prefs(self, args): + """Handle a Preferences view request.""" + self.mainwindow.launch_prefs() + + def launch_browser(self): + start_directory = self.data.top_level_directory + if self.data.top_level_directory is None: + start_directory = os.getcwd() + try: + metomi.rose.external.launch_fs_browser(start_directory) + except metomi.rose.popen.RosePopenError as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + + def launch_graph(self, namespace, allowed_sections=None): + try: + import pygraphviz + except ImportError as exc: + title = metomi.rose.config_editor.WARNING_CANNOT_GRAPH + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, str(exc), title + ) + return + else: + del pygraphviz + config_name = self.util.split_full_ns(self.data, namespace)[0] + self.update_config(config_name) + config_data = self.data.config[config_name] + if config_data.directory is None: + return False + if allowed_sections is None: + if config_name == namespace: + allowed_sections = [] + else: + allowed_sections = ( + self.data.helper.get_sections_from_namespace(namespace) + ) + cmd = ( + shlex.split(metomi.rose.config_editor.LAUNCH_COMMAND_GRAPH) + + [config_data.directory] + + allowed_sections + ) + try: + metomi.rose.popen.RosePopener().run_bg( + *cmd, stdout=sys.stdout, stderr=sys.stderr + ) + except metomi.rose.popen.RosePopenError as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + + def launch_terminal(self): + # Handle a launch terminal request. + try: + metomi.rose.external.launch_terminal() + except metomi.rose.popen.RosePopenError as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + + def launch_output_viewer(self): + """View a suite's output, if any.""" + seproc = ( + metomi.rose.suite_engine_proc.SuiteEngineProcessor.get_processor() + ) + try: + seproc.launch_suite_log_browser(None, self.data.top_level_name) + except metomi.rose.suite_engine_proc.NoSuiteLogError: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.ERROR_NO_OUTPUT.format( + self.data.top_level_name + ), + metomi.rose.config_editor.DIALOG_TITLE_ERROR, + ) + + def transform_default(self, only_this_config=None): + """Run the Rose built-in transformer macros.""" + if only_this_config is not None and only_this_config in list( + self.data.config.keys() + ): + config_keys = [only_this_config] + text = metomi.rose.config_editor.DIALOG_LABEL_AUTOFIX + else: + config_keys = sorted(self.data.config.keys()) + text = metomi.rose.config_editor.DIALOG_LABEL_AUTOFIX_ALL + proceed = metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_WARNING, + text, + metomi.rose.config_editor.DIALOG_TITLE_AUTOFIX, + cancel=True, + ) + if not proceed: + return False + sorter = metomi.rose.config.sort_settings + to_id = lambda s: self.util.get_id_from_section_option( + s.section, s.option + ) + for config_name in config_keys: + macro_config = self.data.dump_to_internal_config(config_name) + meta_config = self.data.config[config_name].meta + macro = metomi.rose.macros.DefaultTransforms() + change_list = macro.transform(macro_config, meta_config)[1] + change_list.sort( + key=cmp_to_key(lambda x, y: sorter(to_id(x), to_id(y))) + ) + self.handle_macro_transforms( + config_name, + "Autofixer.transform", + macro_config, + change_list, + triggers_ok=True, + ) diff --git a/metomi/rose/config_editor/menuwidget.py b/metomi/rose/config_editor/menuwidget.py new file mode 100644 index 0000000000..18ed9054e8 --- /dev/null +++ b/metomi/rose/config_editor/menuwidget.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config_editor +import metomi.rose.config_editor.util +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util + + +class MenuWidget(Gtk.Box): + """This class generates a button with a menu for variable actions.""" + + MENU_ICON_ERRORS = "rose-gtk-gnome-package-system-errors" + MENU_ICON_WARNINGS = "rose-gtk-gnome-package-system-warnings" + MENU_ICON_LATENT = "rose-gtk-gnome-add" + MENU_ICON_LATENT_ERRORS = "rose-gtk-gnome-add-errors" + MENU_ICON_LATENT_WARNINGS = "rose-gtk-gnome-add-warnings" + MENU_ICON_NORMAL = "rose-gtk-gnome-package-system-normal" + + def __init__( + self, variable, var_ops, remove_func, update_func, launch_help_func + ): + super(MenuWidget, self).__init__(homogeneous=False, spacing=0) + self.my_variable = variable + self.var_ops = var_ops + self.trigger_remove = remove_func + self.update_status = update_func + self.launch_help = launch_help_func + self.is_ghost = self.var_ops.is_var_ghost(variable) + self.load_contents() + + def load_contents(self): + """Load the GTK, including menu.""" + variable = self.my_variable + option_ui_start = """ + """ + option_ui_middle = """ + """ + option_ui_end = """ + + + + + """ + actions = [ + ("Options", "rose-gtk-gnome-package-system", ""), + ("Info", Gtk.STOCK_INFO, metomi.rose.config_editor.VAR_MENU_INFO), + ("Help", Gtk.STOCK_HELP, metomi.rose.config_editor.VAR_MENU_HELP), + ( + "Web Help", + Gtk.STOCK_HOME, + metomi.rose.config_editor.VAR_MENU_URL, + ), + ( + "Edit", + Gtk.STOCK_EDIT, + metomi.rose.config_editor.VAR_MENU_EDIT_COMMENTS, + ), + ( + "Fix Ignore", + Gtk.STOCK_CONVERT, + metomi.rose.config_editor.VAR_MENU_FIX_IGNORE, + ), + ( + "Ignore", + Gtk.STOCK_NO, + metomi.rose.config_editor.VAR_MENU_IGNORE, + ), + ( + "Enable", + Gtk.STOCK_YES, + metomi.rose.config_editor.VAR_MENU_ENABLE, + ), + ( + "Remove", + Gtk.STOCK_DELETE, + metomi.rose.config_editor.VAR_MENU_REMOVE, + ), + ("Add", Gtk.STOCK_ADD, metomi.rose.config_editor.VAR_MENU_ADD), + ] + menu_icon_id = "rose-gtk-gnome-package-system" + is_comp = ( + self.my_variable.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + ) + if self.is_ghost or is_comp: + option_ui_middle = option_ui_middle.replace( + "", "" + ) + error_types = metomi.rose.config_editor.WARNING_TYPES_IGNORE + if ( + set(error_types) & set(variable.error.keys()) + or set(error_types) & set(variable.warning.keys()) + or ( + metomi.rose.META_PROP_COMPULSORY in variable.error + and not self.is_ghost + ) + ): + option_ui_middle = ( + "" + + "" + + option_ui_middle + ) + if variable.warning: + if self.is_ghost: + menu_icon_id = self.MENU_ICON_LATENT_WARNINGS + else: + menu_icon_id = self.MENU_ICON_WARNINGS + old_middle = option_ui_middle + option_ui_middle = "" + for warn in variable.warning: + warn_name = warn.replace("/", "_") + option_ui_middle += ( + "" + ) + w_string = "(" + warn.replace("_", "__") + ")" + actions.append( + ("Warn_" + warn_name, Gtk.STOCK_DIALOG_INFO, w_string) + ) + option_ui_middle += "" + old_middle + if variable.error: + if self.is_ghost: + menu_icon_id = self.MENU_ICON_LATENT_ERRORS + else: + menu_icon_id = self.MENU_ICON_ERRORS + old_middle = option_ui_middle + option_ui_middle = "" + for err in variable.error: + err_name = err.replace("/", "_") + option_ui_middle += ( + "" + ) + e_string = "(" + err.replace("_", "__") + ")" + actions.append( + ("Error_" + err_name, Gtk.STOCK_DIALOG_WARNING, e_string) + ) + option_ui_middle += "" + old_middle + if self.is_ghost: + if not variable.error and not variable.warning: + menu_icon_id = self.MENU_ICON_LATENT + option_ui_middle = ( + "" + + "" + + option_ui_middle + ) + if metomi.rose.META_PROP_URL in variable.metadata: + url_ui = "" + option_ui_middle += url_ui + option_ui = option_ui_start + option_ui_middle + option_ui_end + self.button = metomi.rose.gtk.util.CustomButton( + stock_id=menu_icon_id, size=Gtk.IconSize.MENU, as_tool=True + ) + self._set_hover_over(variable) + self.option_ui = option_ui + self.actions = actions + self.pack_start(self.button, expand=False, fill=False, padding=0) + self.button.connect( + "button-press-event", + lambda b, e: self._popup_option_menu( + self.option_ui, self.actions, e + ), + ) + # # FIXME: Try to popup the menu at the button, instead of the cursor. + # self.button.connect( + # "activate", + # lambda b: self._popup_option_menu( + # self.option_ui, + # self.actions, + # Gdk.Event(Gdk.KEY_PRESS))) + self.button.connect( + "enter-notify-event", lambda b, e: self._set_hover_over(variable) + ) + self._set_hover_over(variable) + self.button.show() + + def get_centre_height(self): + """Return the vertical displacement of the centre of this widget.""" + return self.size_request()[1] / 2 + + def refresh(self, variable=None): + """Reload the contents.""" + if variable is not None: + self.my_variable = variable + for widget in self.get_children(): + self.remove(widget) + self.load_contents() + + def _set_hover_over(self, variable): + hover_string = "Variable options" + if variable.warning: + hover_string = metomi.rose.config_editor.VAR_MENU_TIP_WARNING + for warn, warn_info in list(variable.warning.items()): + hover_string += "(" + warn + "): " + warn_info + "\n" + hover_string = hover_string.rstrip("\n") + if variable.error: + hover_string = metomi.rose.config_editor.VAR_MENU_TIP_ERROR + for err, err_info in list(variable.error.items()): + hover_string += "(" + err + "): " + err_info + "\n" + hover_string = hover_string.rstrip("\n") + if self.is_ghost: + if not variable.error: + hover_string = metomi.rose.config_editor.VAR_MENU_TIP_LATENT + self.hover_text = hover_string + self.button.set_tooltip_text(self.hover_text) + self.button.show() + + def _perform_add(self): + self.var_ops.add_var(self.my_variable) + + def _popup_option_menu(self, option_ui, actions, event): + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.set_translation_domain("") + actiongroup.add_actions(actions) + uimanager = Gtk.UIManager() + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string(option_ui) + remove_item = uimanager.get_widget("/Options/Remove") + remove_item.connect("activate", lambda b: self.trigger_remove()) + edit_item = uimanager.get_widget("/Options/Edit") + edit_item.connect("activate", self.launch_edit) + errors = list(self.my_variable.error.keys()) + warnings = list(self.my_variable.warning.keys()) + ns = self.my_variable.metadata["full_ns"] + search_function = lambda i: self.var_ops.search_for_var(ns, i) + dialog_func = metomi.rose.gtk.dialog.run_hyperlink_dialog + for error in errors: + err_name = error.replace("/", "_") + action_name = "Error_" + err_name + if "action='" + action_name + "'" not in option_ui: + continue + err_item = uimanager.get_widget("/Options/" + action_name) + title = ( + metomi.rose.config_editor.DIALOG_VARIABLE_ERROR_TITLE.format( + error, self.my_variable.metadata["id"] + ) + ) + err_item.set_tooltip_text(self.my_variable.error[error]) + err_item.connect( + "activate", + lambda e: dialog_func( + Gtk.STOCK_DIALOG_WARNING, + self.my_variable.error[error], + title, + search_function, + ), + ) + for warning in warnings: + action_name = "Warn_" + warning.replace("/", "_") + if "action='" + action_name + "'" not in option_ui: + continue + warn_item = uimanager.get_widget("/Options/" + action_name) + title = ( + metomi.rose.config_editor.DIALOG_VARIABLE_WARNING_TITLE.format( + warning, self.my_variable.metadata["id"] + ) + ) + warn_item.set_tooltip_text(self.my_variable.warning[warning]) + warn_item.connect( + "activate", + lambda e: dialog_func( + Gtk.STOCK_DIALOG_INFO, + self.my_variable.warning[warning], + title, + search_function, + ), + ) + ignore_item = None + enable_item = None + if "action='Ignore'" in option_ui: + ignore_item = uimanager.get_widget("/Options/Ignore") + if ( + self.my_variable.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + or self.is_ghost + ): + ignore_item.set_sensitive(False) + # It is a non-trigger, optional, enabled variable. + new_reason = { + metomi.rose.variable.IGNORED_BY_USER: ( + metomi.rose.config_editor.IGNORED_STATUS_MANUAL + ) + } + ignore_item.connect( + "activate", + lambda b: self.var_ops.set_var_ignored( + self.my_variable, new_reason + ), + ) + elif "action='Enable'" in option_ui: + enable_item = uimanager.get_widget("/Options/Enable") + enable_item.connect( + "activate", + lambda b: self.var_ops.set_var_ignored(self.my_variable, {}), + ) + if "action='Fix Ignore'" in option_ui: + fix_ignore_item = uimanager.get_widget("/Options/Fix Ignore") + fix_ignore_item.set_tooltip_text( + metomi.rose.config_editor.VAR_MENU_TIP_FIX_IGNORE + ) + fix_ignore_item.connect( + "activate", + lambda e: self.var_ops.fix_var_ignored(self.my_variable), + ) + if ignore_item is not None: + ignore_item.set_sensitive(False) + if enable_item is not None: + enable_item.set_sensitive(False) + info_item = uimanager.get_widget("/Options/Info") + info_item.connect("activate", self._launch_info_dialog) + if ( + self.my_variable.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + or self.is_ghost + ): + remove_item.set_sensitive(False) + help_item = uimanager.get_widget("/Options/Help") + help_item.connect("activate", lambda b: self.launch_help()) + if metomi.rose.META_PROP_HELP not in self.my_variable.metadata: + help_item.set_sensitive(False) + url_item = uimanager.get_widget("/Options/Web Help") + if url_item is not None and "url" in self.my_variable.metadata: + url_item.connect( + "activate", lambda b: self.launch_help(url_mode=True) + ) + if self.is_ghost: + add_item = uimanager.get_widget("/Options/Add") + add_item.connect("activate", lambda b: self._perform_add()) + option_menu = uimanager.get_widget("/Options") + option_menu.attach_to_widget(self.button, lambda m, w: False) + option_menu.show() + option_menu.popup_at_widget( + self.button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + return False + + def _launch_info_dialog(self, *args): + changes = self.var_ops.get_var_changes(self.my_variable) + ns = self.my_variable.metadata["full_ns"] + search_function = lambda i: self.var_ops.search_for_var(ns, i) + metomi.rose.config_editor.util.launch_node_info_dialog( + self.my_variable, changes, search_function + ) + + def launch_edit(self, *args): + text = "\n".join(self.my_variable.comments) + title = metomi.rose.config_editor.DIALOG_TITLE_EDIT_COMMENTS.format( + self.my_variable.metadata["id"] + ) + metomi.rose.gtk.dialog.run_edit_dialog( + text, finish_hook=self._edit_finish_hook, title=title + ) + + def _edit_finish_hook(self, text): + self.var_ops.set_var_comments(self.my_variable, text.splitlines()) + self.update_status() + + +class CheckedMenuWidget(MenuWidget): + """Represent the menu button with a check box instead.""" + + def __init__(self, *args): + super(CheckedMenuWidget, self).__init__(*args) + self.remove(self.button) + for string in [ + "", + "", + "", + ]: + self.option_ui = self.option_ui.replace(string, "") + self.checkbutton = Gtk.CheckButton() + self.checkbutton.set_active(not self.is_ghost) + meta = self.my_variable.metadata + if ( + not self.is_ghost + and meta.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + ): + self.checkbutton.set_sensitive(False) + self.pack_start(self.checkbutton, expand=False, fill=False, padding=0) + self.pack_start(self.button, expand=False, fill=False, padding=0) + self.checkbutton.connect("toggled", self.on_toggle) + self.checkbutton.show() + + def on_toggle(self, widget): + """Handle a toggle.""" + if self.is_ghost: + self._perform_add() + else: + self.trigger_remove() diff --git a/metomi/rose/config_editor/nav_controller.py b/metomi/rose/config_editor/nav_controller.py new file mode 100644 index 0000000000..9e71c16c0c --- /dev/null +++ b/metomi/rose/config_editor/nav_controller.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +from functools import cmp_to_key + +import metomi.rose.config_editor + + +class NavTreeManager(object): + """This controls the navigation namespace tree structure.""" + + def __init__(self, data, util, reporter, tree_trigger_update): + self.data = data + self.util = util + self.reporter = reporter + self.tree_trigger_update = tree_trigger_update + self.namespace_tree = {} # Stores the namespace hierarchy + + def is_ns_in_tree(self, ns): + """Determine if the namespace is in the tree or not.""" + if ns is None: + return False + spaces = ns.lstrip("/").split("/") + subtree = self.namespace_tree + while spaces: + if spaces[0] not in subtree: + return False + subtree = subtree[spaces[0]][0] + spaces.pop(0) + return True + + def reload_namespace_tree( + self, + only_this_namespace=None, + only_this_config=None, + skip_update=False, + ): + """Make the tree of namespaces and load to the tree panel.""" + # Clear the old namespace tree information (selectively if necessary). + if only_this_namespace is not None and only_this_config is None: + config_name = self.util.split_full_ns( + self.data, only_this_namespace + )[0] + only_this_config = config_name + clear_namespace = only_this_namespace.rsplit("/", 1)[0] + self.clear_namespace_tree(clear_namespace) + elif only_this_config is not None: + self.clear_namespace_tree(only_this_config) + else: + self.clear_namespace_tree() + # Reload the information into the tree. + if only_this_config is None: + configs = list(self.data.config.keys()) + configs.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + configs.sort( + key=lambda x: self.data.config[x].config_type + == metomi.rose.TOP_CONFIG_NAME + ) + else: + configs = [only_this_config] + for config_name in configs: + config_data = self.data.config[config_name] + if only_this_namespace: + top_spaces = only_this_namespace.lstrip("/").split("/")[:-1] + else: + top_spaces = config_name.lstrip("/").split("/") + self.update_namespace_tree( + top_spaces, self.namespace_tree, prev_spaces=[] + ) + self.data.load_metadata_for_namespaces(config_name) + # Load tree from sections (usually vast majority of tree nodes) + self.data.load_node_namespaces(config_name) + for section_data in config_data.sections.get_all(): + ns = section_data.metadata["full_ns"] + self.data.namespace_meta_lookup.setdefault(ns, {}) + self.data.namespace_meta_lookup[ns].setdefault( + "title", ns.split("/")[-1] + ) + spaces = ns.lstrip("/").split("/") + self.update_namespace_tree( + spaces, self.namespace_tree, prev_spaces=[] + ) + # Now load tree from variables + for var in config_data.vars.get_all(): + ns = var.metadata["full_ns"] + self.data.namespace_meta_lookup.setdefault(ns, {}) + self.data.namespace_meta_lookup[ns].setdefault( + "title", ns.split("/")[-1] + ) + spaces = ns.lstrip("/").split("/") + self.update_namespace_tree( + spaces, self.namespace_tree, prev_spaces=[] + ) + if not skip_update: + # Perform an update. + self.tree_trigger_update( + only_this_config=only_this_config, + only_this_namespace=only_this_namespace, + ) + + def clear_namespace_tree(self, namespace=None): + """Clear the namespace tree, or a subtree from namespace.""" + if namespace is None: + spaces = [] + else: + spaces = namespace.lstrip("/").split("/") + tree = self.namespace_tree + for space in spaces: + if space not in tree: + break + tree = tree[space][0] + tree.clear() + + def update_namespace_tree(self, spaces, subtree, prev_spaces): + """Recursively load the namespace tree for a single path (spaces). + + The tree is specified with subtree, and it requires an array of names + to load (spaces). + + """ + if spaces: + this_ns = "/" + "/".join(prev_spaces + [spaces[0]]) + change = "" + meta = self.data.namespace_meta_lookup.get(this_ns, {}) + meta.setdefault("title", spaces[0]) + latent_status = self.data.helper.get_ns_latent_status(this_ns) + ignored_status = self.data.helper.get_ns_ignored_status(this_ns) + statuses = { + metomi.rose.config_editor.SHOW_MODE_LATENT: latent_status, + metomi.rose.config_editor.SHOW_MODE_IGNORED: ignored_status, + } + subtree.setdefault(spaces[0], [{}, meta, statuses, change]) + prev_spaces += [spaces[0]] + self.update_namespace_tree( + spaces[1:], subtree[spaces[0]][0], prev_spaces + ) diff --git a/metomi/rose/config_editor/nav_panel.py b/metomi/rose/config_editor/nav_panel.py new file mode 100644 index 0000000000..34123fcd55 --- /dev/null +++ b/metomi/rose/config_editor/nav_panel.py @@ -0,0 +1,693 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re +import sys + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GdkPixbuf +from gi.repository import GObject + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.config_editor.util +import metomi.rose.gtk.util +import metomi.rose.resource + +from functools import cmp_to_key + + +class PageNavigationPanel(Gtk.ScrolledWindow): + """Generate the page launcher panel. + + This contains the namespace groupings as child rows. + Icons denoting changes in the attribute internal data are displayed + next to the attributes. + + """ + + COLUMN_ERROR_ICON = 0 + COLUMN_CHANGE_ICON = 1 + COLUMN_TITLE = 2 + COLUMN_NAME = 3 + COLUMN_ERROR_INTERNAL = 4 + COLUMN_ERROR_TOTAL = 5 + COLUMN_CHANGE_INTERNAL = 6 + COLUMN_CHANGE_TOTAL = 7 + COLUMN_LATENT_STATUS = 8 + COLUMN_IGNORED_STATUS = 9 + COLUMN_TOOLTIP_TEXT = 10 + COLUMN_CHANGE_TEXT = 11 + + def __init__( + self, + namespace_tree, + launch_ns_func, + get_metadata_comments_func, + popup_menu_func, + ask_can_show_func, + ask_is_preview, + ): + super(PageNavigationPanel, self).__init__() + self._launch_ns_func = launch_ns_func + self._get_metadata_comments_func = get_metadata_comments_func + self._popup_menu_func = popup_menu_func + self._ask_can_show_func = ask_can_show_func + self._ask_is_preview = ask_is_preview + self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self.set_shadow_type(Gtk.ShadowType.OUT) + self._rec_no_expand_leaves = re.compile( + metomi.rose.config_editor.TREE_PANEL_NO_EXPAND_LEAVES_REGEX + ) + self.panel_top = Gtk.TreeViewColumn() + self.panel_top.set_title(metomi.rose.config_editor.TREE_PANEL_TITLE) + self.cell_error_icon = Gtk.CellRendererPixbuf() + self.cell_changed_icon = Gtk.CellRendererPixbuf() + self.cell_title = Gtk.CellRendererText() + self.panel_top.pack_start(self.cell_error_icon, False) + self.panel_top.pack_start(self.cell_changed_icon, False) + self.panel_top.pack_start(self.cell_title, False) + self.panel_top.add_attribute( + self.cell_error_icon, + attribute="pixbuf", + column=self.COLUMN_ERROR_ICON, + ) + self.panel_top.add_attribute( + self.cell_changed_icon, + attribute="pixbuf", + column=self.COLUMN_CHANGE_ICON, + ) + self.panel_top.set_cell_data_func( + self.cell_title, self._set_title_markup, self.COLUMN_TITLE + ) + # The columns in self.data_store correspond to: error_icon, + # change_icon, title, name, error and change totals (4), + # latent and ignored statuses, main tip text, and change text. + self.data_store = Gtk.TreeStore( + GdkPixbuf.Pixbuf, + GdkPixbuf.Pixbuf, + str, + str, + int, + int, + int, + int, + bool, + str, + str, + str, + ) + resource_loc = metomi.rose.resource.ResourceLocator(paths=sys.path) + image_path = str(resource_loc.locate("etc/images/rose-config-edit")) + self.null_icon = GdkPixbuf.Pixbuf.new_from_file( + image_path + "/null_icon.png" + ) + self.changed_icon = GdkPixbuf.Pixbuf.new_from_file( + image_path + "/change_icon.png" + ) + self.error_icon = GdkPixbuf.Pixbuf.new_from_file( + image_path + "/error_icon.png" + ) + self.tree = metomi.rose.gtk.util.TooltipTreeView( + get_tooltip_func=self.get_treeview_tooltip + ) + self.tree.append_column(self.panel_top) + self.filter_model = self.data_store.filter_new() + self.filter_model.set_visible_func(self._get_should_show) + self.tree.set_model(self.filter_model) + self.tree.show() + self.name_iter_map = {} + self.add(self.tree) + self.load_tree(None, namespace_tree) + self.tree.connect("button-press-event", self.handle_activation) + self._last_tree_activation_path = None + self.tree.connect("row_activated", self.handle_activation) + self.tree.connect_after("move-cursor", self._handle_cursor_change) + self.tree.connect("key-press-event", self.add_cursor_extra) + self.panel_top.set_clickable(True) + self.panel_top.connect("clicked", lambda c: self.collapse_reset()) + self.show() + self.tree.columns_autosize() + self.tree.connect( + "enter-notify-event", lambda t, e: self.update_row_tooltips() + ) + self.visible_iter_map = {} + + def get_treeview_tooltip(self, view, row_iter, col_index, tip): + """Handle creating a tooltip for the treeview.""" + tip.set_text( + self.filter_model.get_value(row_iter, self.COLUMN_TOOLTIP_TEXT) + ) + return True + + def add_cursor_extra(self, widget, event): + left = event.keyval == Gdk.KEY_Left + right = event.keyval == Gdk.KEY_Right + if left or right: + path = widget.get_cursor()[0] + if path is not None: + if right: + widget.expand_row(path, open_all=False) + elif left: + widget.collapse_row(path) + return False + + def _handle_cursor_change(self, *args): + current_path = self.tree.get_cursor()[0] + if current_path != self._last_tree_activation_path: + GObject.timeout_add( + metomi.rose.config_editor.TREE_PANEL_KBD_TIMEOUT, + self._timeout_launch, + current_path, + ) + + def _timeout_launch(self, timeout_path): + current_path = self.tree.get_cursor()[0] + if ( + current_path == timeout_path + and self._last_tree_activation_path != timeout_path + ): + self._launch_ns_func(self.get_name(timeout_path), as_new=False) + return False + + def load_tree(self, row, namespace_subtree): + expanded_rows = [] + self.tree.map_expanded_rows(lambda r, d: expanded_rows.append(d)) + self.load_tree_stack(row, namespace_subtree) + self.set_expansion() + for this_row in expanded_rows: + self.tree.expand_to_path(this_row) + + def set_expansion(self): + """Set the default expanded rows.""" + top_rows = self.filter_model.iter_n_children(None) + if top_rows > metomi.rose.config_editor.TREE_PANEL_MAX_EXPANDED_ROOTS: + return False + if top_rows == 1: + return self.expand_recursive(no_duplicates=True) + r_iter = self.filter_model.get_iter_first() + while r_iter is not None: + path = self.filter_model.get_path(r_iter) + self.tree.expand_to_path(path) + r_iter = self.filter_model.iter_next(r_iter) + + def load_tree_stack(self, row, namespace_subtree): + """Update the tree store recursively using namespace_subtree.""" + self.name_iter_map = {} + self.visible_iter_map = {} + if row is None: + self.data_store.clear() + initials = list(namespace_subtree.items()) + initials.sort(key=cmp_to_key(self.sort_tree_items)) + stack = [] + if row is None: + start_keylist = [] + else: + path = self.data_store.get_path(row) + start_keylist = self.get_name(path, unfiltered=True).split("/") + for item in initials: + key, value_meta_tuple = item + stack.append([row] + [list(start_keylist)] + list(item)) + self.name_iter_map.setdefault(True, {}) # True maps to unfiltered. + name_iter_map = self.name_iter_map[True] + while stack: + row, keylist, key, value_meta_tuple = stack[0] + value, meta, statuses, change = value_meta_tuple + title = meta[metomi.rose.META_PROP_TITLE] + latent_status = statuses[ + metomi.rose.config_editor.SHOW_MODE_LATENT + ] + ignored_status = statuses[ + metomi.rose.config_editor.SHOW_MODE_IGNORED + ] + new_row = self.data_store.append( + row, + [ + self.null_icon, + self.null_icon, + title, + key, + 0, + 0, + 0, + 0, + latent_status, + ignored_status, + "", + change, + ], + ) + new_keylist = keylist + [key] + name_iter_map["/".join(new_keylist)] = new_row + if isinstance(value, dict): + newer_initials = list(value.items()) + newer_initials.sort(key=cmp_to_key(self.sort_tree_items)) + for vals in newer_initials: + stack.append([new_row] + [list(new_keylist)] + list(vals)) + stack.pop(0) + + def _set_title_markup(self, column, cell, model, r_iter, index): + title = model.get_value(r_iter, index) + title = metomi.rose.gtk.util.safe_str(title) + if len(model.get_path(r_iter)) == 1: + title = metomi.rose.config_editor.TITLE_PAGE_ROOT_MARKUP.format( + title + ) + latent_status = model.get_value(r_iter, self.COLUMN_LATENT_STATUS) + ignored_status = model.get_value(r_iter, self.COLUMN_IGNORED_STATUS) + name = self.get_name(model.get_path(r_iter)) + preview_status = self._ask_is_preview(name) + if preview_status: + title = metomi.rose.config_editor.TITLE_PAGE_PREVIEW_MARKUP.format( + title + ) + if latent_status: + if self._get_is_latent_sub_tree(model, r_iter): + title = ( + metomi.rose.config_editor.TITLE_PAGE_LATENT_MARKUP.format( + title + ) + ) + if ignored_status: + title = metomi.rose.config_editor.TITLE_PAGE_IGNORED_MARKUP.format( + ignored_status, title + ) + cell.set_property("markup", title) + + def sort_tree_items(self, row_item_1, row_item_2): + """Sort tree items according to name and sort key.""" + sort_key_1 = row_item_1[1][1].get(metomi.rose.META_PROP_SORT_KEY, "~") + sort_key_2 = row_item_2[1][1].get(metomi.rose.META_PROP_SORT_KEY, "~") + var_id_1 = row_item_1[0] + var_id_2 = row_item_2[0] + + x_key = (sort_key_1, var_id_1) + y_key = (sort_key_2, var_id_2) + + return metomi.rose.config_editor.util.null_cmp(x_key, y_key) + + def set_row_icon(self, names, ind_count=0, ind_type="changed"): + """Set the icons for row status on or off. Check parent icons. + + After updating the row which is specified by a list of namespace + pieces (names), go up through the tree and update parent row icons + according to the status of their child row icons. + + """ + ind_map = { + "changed": { + "icon_col": self.COLUMN_CHANGE_ICON, + "icon": self.changed_icon, + "int_col": self.COLUMN_CHANGE_INTERNAL, + "total_col": self.COLUMN_CHANGE_TOTAL, + }, + "error": { + "icon_col": self.COLUMN_ERROR_ICON, + "icon": self.error_icon, + "int_col": self.COLUMN_ERROR_INTERNAL, + "total_col": self.COLUMN_ERROR_TOTAL, + }, + } + int_col = ind_map[ind_type]["int_col"] + total_col = ind_map[ind_type]["total_col"] + row_path = self.get_path_from_names(names, unfiltered=True) + if row_path is None: + return False + row_iter = self.data_store.get_iter(row_path) + old_total = self.data_store.get_value(row_iter, total_col) + old_int = self.data_store.get_value(row_iter, int_col) + diff_int_count = ind_count - old_int + if diff_int_count == 0: + # No change. + return False + new_total = old_total + diff_int_count + self.data_store.set_value(row_iter, int_col, ind_count) + self.data_store.set_value(row_iter, total_col, new_total) + if new_total > 0: + self.data_store.set_value( + row_iter, + ind_map[ind_type]["icon_col"], + ind_map[ind_type]["icon"], + ) + else: + self.data_store.set_value( + row_iter, ind_map[ind_type]["icon_col"], self.null_icon + ) + + # Now pass information up the tree + for parent in [row_path[:i] for i in range(len(row_path) - 1, 0, -1)]: + parent_iter = self.data_store.get_iter(parent) + old_parent_total = self.data_store.get_value( + parent_iter, total_col + ) + new_parent_total = old_parent_total + diff_int_count + self.data_store.set_value(parent_iter, total_col, new_parent_total) + if new_parent_total > 0: + self.data_store.set_value( + parent_iter, + ind_map[ind_type]["icon_col"], + ind_map[ind_type]["icon"], + ) + else: + self.data_store.set_value( + parent_iter, ind_map[ind_type]["icon_col"], self.null_icon + ) + + def update_row_tooltips(self): + """Synchronise the icon information with the hover-over text.""" + my_iter = self.data_store.get_iter_first() + if my_iter is None: + return + paths = [] + iter_stack = [my_iter] + while iter_stack: + my_iter = iter_stack.pop(0) + paths.append(self.data_store.get_path(my_iter)) + next_iter = self.data_store.iter_next(my_iter) + if next_iter is not None: + iter_stack.append(next_iter) + if self.data_store.iter_has_child(my_iter): + iter_stack.append(self.data_store.iter_children(my_iter)) + for path in paths: + path_iter = self.data_store.get_iter(path) + title = self.data_store.get_value(path_iter, self.COLUMN_TITLE) + name = self.data_store.get_value(path_iter, self.COLUMN_NAME) + num_errors = self.data_store.get_value( + path_iter, self.COLUMN_ERROR_INTERNAL + ) + mods = self.data_store.get_value( + path_iter, self.COLUMN_CHANGE_INTERNAL + ) + proper_name = self.get_name(path, unfiltered=True) + metadata, comment = self._get_metadata_comments_func(proper_name) + description = metadata.get(metomi.rose.META_PROP_DESCRIPTION, "") + change = self.data_store.get_value( + path_iter, self.COLUMN_CHANGE_TEXT + ) + text = title + if name != title: + text += " (" + name + ")" + if mods > 0: + text += " - " + metomi.rose.config_editor.TREE_PANEL_MODIFIED + if description: + text += ":\n" + description + if num_errors > 0: + if num_errors == 1: + text += metomi.rose.config_editor.TREE_PANEL_ERROR + else: + text += metomi.rose.config_editor.TREE_PANEL_ERRORS.format( + num_errors + ) + if comment: + text += "\n" + comment + if change: + text += "\n\n" + change + self.data_store.set_value( + path_iter, self.COLUMN_TOOLTIP_TEXT, text + ) + + def update_change(self, row_names, new_change): + """Update 'changed' text.""" + self._set_row_names_value( + row_names, self.COLUMN_CHANGE_TEXT, new_change + ) + + def update_statuses(self, row_names, latent_status, ignored_status): + """Update latent and ignored statuses.""" + self._set_row_names_value( + row_names, self.COLUMN_LATENT_STATUS, latent_status + ) + self._set_row_names_value( + row_names, self.COLUMN_IGNORED_STATUS, ignored_status + ) + + def _set_row_names_value(self, row_names, index, value): + path = self.get_path_from_names(row_names, unfiltered=True) + if path is not None: + row_iter = self.data_store.get_iter(path) + self.data_store.set_value(row_iter, index, value) + + def select_row(self, row_names): + """Highlight one particular row, but only this one.""" + if row_names is None: + return + path = self.get_path_from_names(row_names, unfiltered=True) + try: + path = self.filter_model.convert_child_path_to_path(path) + except TypeError: + path = None + if path is None: + dest_path = (0,) + else: + i = 1 + # removed the path[:i] in here + while self.tree.row_expanded(path) and i <= len(path): + i += 1 + dest_path = path + cursor_path = self.tree.get_cursor()[0] + if cursor_path != dest_path: + self.tree.set_cursor(dest_path) + + def get_path_from_names(self, row_names, unfiltered=False): + """Return a row path corresponding to the list of branch names.""" + if unfiltered: + tree_model = self.data_store + else: + tree_model = self.filter_model + self.name_iter_map.setdefault(unfiltered, {}) + name_iter_map = self.name_iter_map[unfiltered] + key = "/".join(row_names) + if key in name_iter_map: + return tree_model.get_path(name_iter_map[key]) + if unfiltered: + # This would be cached in name_iter_map by load_tree_stack. + return None + my_iter = tree_model.get_iter_first() + these_names = [] + good_paths = [row_names[:i] for i in range(len(row_names) + 1)] + for names in reversed(good_paths): + subkey = "/".join(names) + if subkey in name_iter_map: + my_iter = name_iter_map[subkey] + these_names = names[:-1] + break + while my_iter is not None: + branch_name = tree_model.get_value(my_iter, self.COLUMN_NAME) + my_names = these_names + [branch_name] + subkey = "/".join(my_names) + name_iter_map[subkey] = my_iter + if my_names in good_paths: + if my_names == row_names: + return tree_model.get_path(my_iter) + else: + these_names.append(branch_name) + my_iter = tree_model.iter_children(my_iter) + else: + my_iter = tree_model.iter_next(my_iter) + return None + + def get_change_error_totals(self, config_name=None): + """Return the number of changes and total errors for the root nodes.""" + if config_name: + path = self.get_path_from_names([config_name], unfiltered=True) + iter_ = self.data_store.get_iter(path) + else: + iter_ = self.data_store.get_iter_first() + changes = 0 + errors = 0 + while iter_ is not None: + iter_changes = self.data_store.get_value( + iter_, self.COLUMN_CHANGE_TOTAL + ) + iter_errors = self.data_store.get_value( + iter_, self.COLUMN_ERROR_TOTAL + ) + if iter_changes is not None: + changes += iter_changes + if iter_errors is not None: + errors += iter_errors + if config_name: + break + else: + iter_ = self.data_store.iter_next(iter_) + return changes, errors + + def handle_activation(self, treeview=None, event=None, somewidget=None): + """Send a page launch request based on left or middle clicks.""" + if event is not None and treeview is not None: + if hasattr(event, "button"): + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is not None: + path, col, cell_x, _ = pathinfo + if ( + treeview.get_expander_column() == col + and cell_x < 1 + 18 * len(path) + ): # Hardwired, bad. + if event.button != 3: + return False + else: + return self.expand_recursive( + start_path=path, no_duplicates=True + ) + if event.button == 3: + self.popup_menu(path, event) + else: + treeview.grab_focus() + treeview.set_cursor(pathinfo[0], col, 0) + elif event.button == 3: # Right clicked outside the rows + self.popup_menu(None, event) + else: # Clicked outside the rows + return False + if event.button == 1: # Left click event, replace old tab + self._last_tree_activation_path = path + self._launch_ns_func(self.get_name(path), as_new=False) + elif event.button == 2: # Middle click event, make new tab + self._last_tree_activation_path = path + self._launch_ns_func(self.get_name(path), as_new=True) + else: + path = event + self._launch_ns_func(self.get_name(path), as_new=False) + return False + + def get_name(self, path=None, unfiltered=False): + """Return the row name (text) corresponding to the treeview path.""" + if path is None: + tree_selection = self.tree.get_selection() + (tree_model, tree_iter) = tree_selection.get_selected() + path = tree_model.get_path(tree_iter) + else: + tree_model = self.tree.get_model() + if unfiltered: + tree_model = tree_model.get_model() + tree_iter = tree_model.get_iter(path) + row_name = str(tree_model.get_value(tree_iter, self.COLUMN_NAME)) + full_name = row_name + for parent in [path[:i] for i in range(len(path) - 1, 0, -1)]: + parent_iter = tree_model.get_iter(parent) + full_name = str( + tree_model.get_value(parent_iter, self.COLUMN_NAME) + + "/" + + full_name + ) + return full_name + + def get_subtree_names(self, path=None): + """Return all names that exist in a subtree of path.""" + tree_model = self.tree.get_model() + root_iter = tree_model.get_iter(path) + sub_iters = [] + for i in range(tree_model.iter_n_children(root_iter)): + sub_iters.append(tree_model.iter_nth_child(root_iter, i)) + sub_names = [] + while sub_iters: + if sub_iters[0] is not None: + path = tree_model.get_path(sub_iters[0]) + sub_names.append(self.get_name(path)) + for i in range(tree_model.iter_n_children(sub_iters[0])): + sub_iters.append( + tree_model.iter_nth_child(sub_iters[0], i) + ) + sub_iters.pop(0) + return sub_names + + def popup_menu(self, path, event): + """Launch a popup menu for add/clone/remove.""" + if path: + path_name = "/" + self.get_name(path) + else: + path_name = None + return self._popup_menu_func(path_name, event) + + def collapse_reset(self): + """Return the tree view to the basic startup state.""" + self.tree.collapse_all() + self.set_expansion() + self.tree.grab_focus() + return False + + def expand_recursive(self, start_path=None, no_duplicates=False): + """Expand the tree starting at start_path.""" + treemodel = self.tree.get_model() + if start_path is None: + start_iter = treemodel.get_iter_first() + start_path = treemodel.get_path(start_iter) + if not no_duplicates: + return self.tree.expand_row(start_path, open_all=True) + max_depth = metomi.rose.config_editor.TREE_PANEL_MAX_EXPANDED_DEPTH + stack = [treemodel.get_iter(start_path)] + while stack: + iter_ = stack.pop(0) + if iter_ is None: + continue + path = treemodel.get_path(iter_) + name = self.get_name(path) + child_iter = treemodel.iter_children(iter_) + child_dups = [] + while child_iter is not None: + child_name = self.get_name(treemodel.get_path(child_iter)) + metadata = self._get_metadata_comments_func(child_name)[0] + dupl = metadata.get(metomi.rose.META_PROP_DUPLICATE) + child_dups.append(dupl == metomi.rose.META_PROP_VALUE_TRUE) + child_iter = treemodel.iter_next(child_iter) + if path != start_path: + stack.append(treemodel.iter_next(iter_)) + if ( + not all(child_dups) + and len(path) <= max_depth + and not self._rec_no_expand_leaves.search(name) + ): + self.tree.expand_row(path, open_all=False) + stack.append(treemodel.iter_children(iter_)) + + def _get_is_latent_sub_tree(self, model, iter_): + """Return True if the whole model sub tree is latent.""" + if not model.get_value(iter_, self.COLUMN_LATENT_STATUS): + # This row is not latent. + return False + iter_stack = [model.iter_children(iter_)] + while iter_stack: + iter_ = iter_stack.pop(0) + if iter_ is None: + continue + if not model.get_value(iter_, self.COLUMN_LATENT_STATUS): + # This sub-row is not latent. + return False + iter_stack.append(model.iter_children(iter_)) + iter_stack.append(model.iter_next(iter_)) + return True + + def _get_should_show(self, model, iter_, _): + # Determine whether to show a row. + latent_status = model.get_value(iter_, self.COLUMN_LATENT_STATUS) + ignored_status = model.get_value(iter_, self.COLUMN_IGNORED_STATUS) + has_error = bool(model.get_value(iter_, self.COLUMN_ERROR_INTERNAL)) + child_iter = model.iter_children(iter_) + is_visible = self._ask_can_show_func( + latent_status, ignored_status, has_error + ) + if is_visible: + return True + while child_iter is not None: + if self._get_should_show(model, child_iter, _): + return True + child_iter = model.iter_next(child_iter) + return False diff --git a/metomi/rose/config_editor/nav_panel_menu.py b/metomi/rose/config_editor/nav_panel_menu.py new file mode 100644 index 0000000000..bef5201868 --- /dev/null +++ b/metomi/rose/config_editor/nav_panel_menu.py @@ -0,0 +1,671 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import os +import time +import webbrowser + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config +import metomi.rose.config_editor.util +import metomi.rose.gtk.dialog + +from functools import cmp_to_key + + +class NavPanelHandler(object): + """Handles the navigation panel menu.""" + + def __init__( + self, + data, + util, + reporter, + mainwindow, + undo_stack, + redo_stack, + add_config_func, + group_ops_inst, + section_ops_inst, + variable_ops_inst, + kill_page_func, + reload_ns_tree_func, + transform_default_func, + graph_ns_func, + ): + self.data = data + self.util = util + self.reporter = reporter + self.mainwindow = mainwindow + self.undo_stack = undo_stack + self.redo_stack = redo_stack + self.group_ops = group_ops_inst + self.sect_ops = section_ops_inst + self.var_ops = variable_ops_inst + self._add_config = add_config_func + self.kill_page_func = kill_page_func + self.reload_ns_tree_func = reload_ns_tree_func + self._transform_default_func = transform_default_func + self._graph_ns_func = graph_ns_func + + def add_dialog(self, base_ns): + """Handle an add section dialog and request.""" + if base_ns is not None and "/" in base_ns: + config_name, subsp = self.util.split_full_ns(self.data, base_ns) + config_data = self.data.config[config_name] + if config_name == base_ns: + help_str = "" + else: + sections = self.data.helper.get_sections_from_namespace( + base_ns + ) + if sections == []: + help_str = subsp.replace("/", ":") + else: + help_str = sections[0] + help_str = help_str.split(":", 1)[0] + for config_section in list( + config_data.sections.now.keys() + ) + list(config_data.sections.latent.keys()): + if config_section.startswith(help_str + ":"): + help_str = help_str + ":" + else: + help_str = None + config_name = None + choices_help = self.data.helper.get_missing_sections(config_name) + + config_names = [ + n for n in self.data.config if not self.ask_is_preview(n) + ] + config_names.sort( + key=cmp_to_key( + lambda x, y: (y == config_name) - (x == config_name) + ) + ) + config_name, section = self.mainwindow.launch_add_dialog( + config_names, choices_help, help_str + ) + if config_name in self.data.config and section is not None: + self.sect_ops.add_section(config_name, section, page_launch=True) + + def ask_is_preview(self, base_ns): + namespace = "/" + base_ns.lstrip("/") + try: + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + return config_data.is_preview + except KeyError: + return False + + def copy_request(self, base_ns, new_section=None, skip_update=False): + """Handle a copy request for a section and its options.""" + namespace = "/" + base_ns.lstrip("/") + sections = self.data.helper.get_sections_from_namespace(namespace) + if len(sections) != 1: + return False + section = sections.pop() + config_name = self.util.split_full_ns(self.data, namespace)[0] + return self.group_ops.copy_section( + config_name, section, skip_update=skip_update + ) + + def create_request(self): + """Handle a create configuration request.""" + if not any( + v.config_type == metomi.rose.TOP_CONFIG_NAME + for v in list(self.data.config.values()) + ): + text = metomi.rose.config_editor.WARNING_APP_CONFIG_CREATE + title = metomi.rose.config_editor.WARNING_APP_CONFIG_CREATE_TITLE + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + return False + # Need an application configuration to be created. + root = os.path.join( + self.data.top_level_directory, metomi.rose.SUB_CONFIGS_DIR + ) + name, meta = self.mainwindow.launch_new_config_dialog(root) + if name is None: + return False + config_name = "/" + name + self._add_config(config_name, meta) + + def ignore_request(self, base_ns, is_ignored): + """Handle an ignore or enable section request.""" + config_names = list(self.data.config.keys()) + if base_ns is not None and "/" in base_ns: + config_name = self.util.split_full_ns(self.data, base_ns)[0] + prefer_name_sections = { + config_name: self.data.helper.get_sections_from_namespace( + base_ns + ) + } + else: + prefer_name_sections = {} + config_sect_dict = {} + for config_name in config_names: + config_data = self.data.config[config_name] + config_sect_dict[config_name] = [] + sect_and_data = list(config_data.sections.now.items()) + for v_sect in config_data.vars.now: + sect_data = config_data.sections.now[v_sect] + sect_and_data.append((v_sect, sect_data)) + for section, sect_data in sect_and_data: + if section not in config_sect_dict[config_name]: + if sect_data.ignored_reason: + if is_ignored: + continue + if not is_ignored: + mode = sect_data.metadata.get( + metomi.rose.META_PROP_COMPULSORY + ) + if ( + not sect_data.ignored_reason + or mode == metomi.rose.META_PROP_VALUE_TRUE + ): + continue + config_sect_dict[config_name].append(section) + config_sect_dict[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + if config_name in prefer_name_sections: + prefer_name_sections[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + config_name, section = self.mainwindow.launch_ignore_dialog( + config_sect_dict, prefer_name_sections, is_ignored + ) + if config_name in self.data.config and section is not None: + self.sect_ops.ignore_section(config_name, section, is_ignored) + + def edit_request(self, base_ns): + """Handle a request for editing section comments.""" + if base_ns is None: + return False + base_ns = "/" + base_ns.lstrip("/") + config_name = self.util.split_full_ns(self.data, base_ns)[0] + config_data = self.data.config[config_name] + sections = self.data.helper.get_sections_from_namespace(base_ns) + for section in list(sections): + if section not in config_data.sections.now: + sections.remove(section) + if not sections: + return False + if len(sections) > 1: + section = metomi.rose.gtk.dialog.run_choices_dialog( + metomi.rose.config_editor.DIALOG_LABEL_CHOOSE_SECTION_EDIT, + sections, + metomi.rose.config_editor.DIALOG_TITLE_CHOOSE_SECTION, + ) + else: + section = sections[0] + if section is None: + return False + title = metomi.rose.config_editor.DIALOG_TITLE_EDIT_COMMENTS.format( + section + ) + text = "\n".join(config_data.sections.now[section].comments) + finish = lambda t: self.sect_ops.set_section_comments( + config_name, section, t.splitlines() + ) + metomi.rose.gtk.dialog.run_edit_dialog( + text, finish_hook=finish, title=title + ) + + def fix_request(self, base_ns): + """Handle a request to auto-fix a configuration.""" + if base_ns is None: + return False + base_ns = "/" + base_ns.lstrip("/") + config_name = self.util.split_full_ns(self.data, base_ns)[0] + self._transform_default_func(only_this_config=config_name) + + def get_ns_metadata_and_comments(self, namespace): + """Return metadata dict and comments list.""" + namespace = "/" + namespace.lstrip("/") + metadata = {} + comments = "" + if namespace is None: + return metadata, comments + metadata = self.data.namespace_meta_lookup.get(namespace, {}) + comments = self.data.helper.get_ns_comment_string(namespace) + return metadata, comments + + def info_request(self, namespace): + """Handle a request for namespace info.""" + if namespace is None: + return False + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + sections = self.data.helper.get_sections_from_namespace(namespace) + search_function = lambda i: self.search_request(namespace, i) + for section in sections: + sect_data = config_data.sections.now.get(section) + if sect_data is not None: + metomi.rose.config_editor.util.launch_node_info_dialog( + sect_data, "", search_function + ) + + def graph_request(self, namespace): + """Handle a graph request for namespace info.""" + self._graph_ns_func(namespace) + + def remove_request(self, base_ns): + """Handle a delete section request.""" + config_names = list(self.data.config.keys()) + if base_ns is not None and "/" in base_ns: + config_name = self.util.split_full_ns(self.data, base_ns)[0] + prefer_name_sections = { + config_name: self.data.helper.get_sections_from_namespace( + base_ns + ) + } + else: + prefer_name_sections = {} + config_sect_dict = {} + for config_name in config_names: + config_data = self.data.config[config_name] + config_sect_dict[config_name] = list( + config_data.sections.now.keys() + ) + config_sect_dict[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + if config_name in prefer_name_sections: + prefer_name_sections[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + config_name, section = self.mainwindow.launch_remove_dialog( + config_sect_dict, prefer_name_sections + ) + if config_name in self.data.config and section is not None: + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_DELETE + + "-" + + str(time.time()) + ) + config_data = self.data.config[config_name] + variable_sorter = lambda v, w: metomi.rose.config.sort_settings( + v.metadata["id"], w.metadata["id"] + ) + variables = list(config_data.vars.now.get(section, [])) + variables.sort(key=cmp_to_key(variable_sorter)) + variables.reverse() + for variable in variables: + self.var_ops.remove_var(variable) + self.sect_ops.remove_section(config_name, section) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + + def rename_dialog(self, base_ns): + """Handle a rename section dialog and request.""" + if base_ns is not None and "/" in base_ns: + config_name = self.util.split_full_ns(self.data, base_ns)[0] + prefer_name_sections = { + config_name: self.data.helper.get_sections_from_namespace( + base_ns + ) + } + else: + prefer_name_sections = {} + config_sect_dict = {} + for config_name in self.data.config: + config_data = self.data.config[config_name] + config_sect_dict[config_name] = list( + config_data.sections.now.keys() + ) + config_sect_dict[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + if config_name in prefer_name_sections: + prefer_name_sections[config_name].sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + config_name, source_section, target_section = ( + self.mainwindow.launch_rename_dialog( + config_sect_dict, prefer_name_sections + ) + ) + if ( + config_name in self.data.config + and source_section is not None + and target_section + ): + self.group_ops.rename_section( + config_name, source_section, target_section + ) + + def search_request(self, namespace, setting_id): + """Handle a search for an id (hyperlink).""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + self.var_ops.search_for_var(config_name, setting_id) + + def popup_panel_menu(self, base_ns, event): + """Popup a page menu on the navigation panel.""" + if base_ns is None: + namespace = None + else: + namespace = "/" + base_ns.lstrip("/") + + ui_config_string = """ """ + actions = [ + ( + "New", + Gtk.STOCK_NEW, + metomi.rose.config_editor.TREE_PANEL_NEW_CONFIG, + ), + ( + "Add", + Gtk.STOCK_ADD, + metomi.rose.config_editor.TREE_PANEL_ADD_GENERIC, + ), + ( + "Autofix", + Gtk.STOCK_CONVERT, + metomi.rose.config_editor.TREE_PANEL_AUTOFIX_CONFIG, + ), + ( + "Clone", + Gtk.STOCK_COPY, + metomi.rose.config_editor.TREE_PANEL_CLONE_SECTION, + ), + ( + "Edit", + Gtk.STOCK_EDIT, + metomi.rose.config_editor.TREE_PANEL_EDIT_SECTION, + ), + ( + "Enable", + Gtk.STOCK_YES, + metomi.rose.config_editor.TREE_PANEL_ENABLE_GENERIC, + ), + ( + "Graph", + Gtk.STOCK_SORT_ASCENDING, + metomi.rose.config_editor.TREE_PANEL_GRAPH_SECTION, + ), + ( + "Ignore", + Gtk.STOCK_NO, + metomi.rose.config_editor.TREE_PANEL_IGNORE_GENERIC, + ), + ( + "Info", + Gtk.STOCK_INFO, + metomi.rose.config_editor.TREE_PANEL_INFO_SECTION, + ), + ( + "Help", + Gtk.STOCK_HELP, + metomi.rose.config_editor.TREE_PANEL_HELP_SECTION, + ), + ( + "URL", + Gtk.STOCK_HOME, + metomi.rose.config_editor.TREE_PANEL_URL_SECTION, + ), + ( + "Remove", + Gtk.STOCK_DELETE, + metomi.rose.config_editor.TREE_PANEL_REMOVE_GENERIC, + ), + ( + "Rename", + Gtk.STOCK_COPY, + metomi.rose.config_editor.TREE_PANEL_RENAME_GENERIC, + ), + ] + url = None + help_ = None + is_empty = not self.data.config + if namespace is not None: + config_name = self.util.split_full_ns(self.data, namespace)[0] + if self.data.config[config_name].is_preview: + return False + cloneable = self.is_ns_duplicate(namespace) + is_top = namespace in list(self.data.config.keys()) + is_fixable = bool(self.get_ns_errors(namespace)) + has_content = self.data.helper.is_ns_content(namespace) + is_unsaved = self.data.helper.get_config_has_unsaved_changes( + config_name + ) + is_latent = self.data.helper.get_ns_latent_status(namespace) + latent_sections = self.data.helper.get_latent_sections(namespace) + metadata = self.get_ns_metadata_and_comments(namespace)[0] + if is_latent: + for i, section in enumerate(latent_sections): + action_name = "Add {0}".format(i) + ui_config_string += ''.format( + action_name + ) + actions.append( + ( + action_name, + Gtk.STOCK_ADD, + metomi.rose.config_editor.TREE_PANEL_ADD_SECTION.format( # noqa: E501 + section.replace("_", "__") + ), + ) + ) + ui_config_string += '' + ui_config_string += '' + if cloneable: + ui_config_string += '' + ui_config_string += '' + if not is_empty: + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + if has_content: + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + url = metadata.get(metomi.rose.META_PROP_URL) + help_ = metadata.get(metomi.rose.META_PROP_HELP) + if url is not None or help_ is not None: + ui_config_string += '' + if url is not None: + ui_config_string += '' + if help_ is not None: + ui_config_string += '' + if not is_empty: + ui_config_string += """""" + ui_config_string += """""" + if is_fixable: + ui_config_string += """ + """ + else: + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + ui_config_string += '' + if namespace is None or (is_top or is_empty): + ui_config_string += """ + """ + ui_config_string += """ """ + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string(ui_config_string) + if namespace is None or (is_top or is_empty): + new_item = uimanager.get_widget("/Popup/New") + new_item.connect("activate", lambda b: self.create_request()) + new_item.set_sensitive(not is_empty) + add_item = uimanager.get_widget("/Popup/Add") + add_item.connect("activate", lambda b: self.add_dialog(namespace)) + add_item.set_sensitive(not is_empty) + enable_item = uimanager.get_widget("/Popup/Enable") + enable_item.connect( + "activate", lambda b: self.ignore_request(namespace, False) + ) + enable_item.set_sensitive(not is_empty) + ignore_item = uimanager.get_widget("/Popup/Ignore") + ignore_item.connect( + "activate", lambda b: self.ignore_request(namespace, True) + ) + ignore_item.set_sensitive(not is_empty) + if namespace is not None: + if is_latent: + for i, section in enumerate(latent_sections): + action_name = "Add {0}".format(i) + add_item = uimanager.get_widget("/Popup/" + action_name) + add_item._section = section + add_item.connect( + "activate", + lambda b: self.sect_ops.add_section( + config_name, b._section + ), + ) + if cloneable: + clone_item = uimanager.get_widget("/Popup/Clone") + clone_item.connect( + "activate", lambda b: self.copy_request(namespace) + ) + if has_content: + edit_item = uimanager.get_widget("/Popup/Edit") + edit_item.connect( + "activate", lambda b: self.edit_request(namespace) + ) + info_item = uimanager.get_widget("/Popup/Info") + info_item.connect( + "activate", lambda b: self.info_request(namespace) + ) + graph_item = uimanager.get_widget("/Popup/Graph") + graph_item.connect( + "activate", lambda b: self.graph_request(namespace) + ) + if is_unsaved: + graph_item.set_sensitive(False) + if help_ is not None: + help_item = uimanager.get_widget("/Popup/Help") + help_title = namespace.split("/")[1:] + help_title = ( + metomi.rose.config_editor.DIALOG_HELP_TITLE.format( + help_title + ) + ) + search_function = lambda i: self.search_request(namespace, i) + help_item.connect( + "activate", + lambda b: metomi.rose.gtk.dialog.run_hyperlink_dialog( + Gtk.STOCK_DIALOG_INFO, + help_, + help_title, + search_function, + ), + ) + if url is not None: + url_item = uimanager.get_widget("/Popup/URL") + url_item.connect("activate", lambda b: webbrowser.open(url)) + if is_fixable: + autofix_item = uimanager.get_widget("/Popup/Autofix") + autofix_item.connect( + "activate", lambda b: self.fix_request(namespace) + ) + remove_section_item = uimanager.get_widget("/Popup/Remove") + remove_section_item.connect( + "activate", lambda b: self.remove_request(namespace) + ) + rename_section_item = uimanager.get_widget("/Popup/Rename") + rename_section_item.connect( + "activate", lambda b: self.rename_dialog(namespace) + ) + menu = uimanager.get_widget("/Popup") + menu.popup_at_pointer(event) + return False + + def is_ns_duplicate(self, namespace): + """Lookup whether a page can be cloned, via the metadata.""" + sections = self.data.helper.get_sections_from_namespace(namespace) + if len(sections) != 1: + return False + section = sections.pop() + config_name = self.util.split_full_ns(self.data, namespace)[0] + sect_data = self.data.config[config_name].sections.now.get(section) + if sect_data is None: + return False + return ( + sect_data.metadata.get(metomi.rose.META_PROP_DUPLICATE) + == metomi.rose.META_PROP_VALUE_TRUE + ) + + def get_ns_errors(self, namespace): + """Count the number of errors in a namespace.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + sections = self.data.helper.get_sections_from_namespace(namespace) + errors = 0 + for section in sections: + errors += len(config_data.sections.get_sect(section).error) + real_data, latent_data = self.data.helper.get_data_for_namespace( + namespace + ) + errors += sum([len(v.error) for v in real_data + latent_data]) + return errors + + def get_ns_ignored(self, base_ns): + """Lookup the ignored status of a namespace's data.""" + namespace = "/" + base_ns.lstrip("/") + return self.data.helper.get_ns_ignored_status(namespace) + + def get_can_show_page(self, latent_status, ignored_status, has_error): + """Lookup whether to display a page based on the data status.""" + if has_error or (not ignored_status and not latent_status): + # Always show this. + return True + show_ignored = self.data.page_ns_show_modes[ + metomi.rose.config_editor.SHOW_MODE_IGNORED + ] + show_user_ignored = self.data.page_ns_show_modes[ + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED + ] + show_latent = self.data.page_ns_show_modes[ + metomi.rose.config_editor.SHOW_MODE_LATENT + ] + if latent_status: + if not show_latent: + # Latent page, no latent pages allowed. + return False + # Latent page, latent pages allowed (but may be ignored...). + if ignored_status: + if ( + ignored_status + == metomi.rose.config.ConfigNode.STATE_USER_IGNORED + ): + if show_ignored or show_user_ignored: + # This is an allowed user-ignored page. + return True + # This is a user-ignored page that isn't allowed. + return False + # This is a trigger-ignored page that may be allowed. + return show_ignored + # This is a latent page that isn't ignored, latent pages allowed. + return True diff --git a/metomi/rose/config_editor/ops/__init__.py b/metomi/rose/config_editor/ops/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metomi/rose/config_editor/ops/group.py b/metomi/rose/config_editor/ops/group.py new file mode 100644 index 0000000000..db269d083b --- /dev/null +++ b/metomi/rose/config_editor/ops/group.py @@ -0,0 +1,577 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This module handles grouped operations. + +These are supersets of section and variable operations, such as adding +a section together with some variables, mass removal of sections, +copying sections with their variables, and so on. + +""" + +import copy +import re +import time + +from functools import cmp_to_key + +import metomi.rose.config +import metomi.rose.config_editor + + +class GroupOperations(object): + """Class to perform actions on groups of sections and/or options.""" + + def __init__( + self, + data, + util, + reporter, + undo_stack, + redo_stack, + section_ops_inst, + variable_ops_inst, + view_page_func, + update_ns_sub_data_func, + reload_ns_tree_func, + ): + self.data = data + self.util = util + self.reporter = reporter + self.undo_stack = undo_stack + self.redo_stack = redo_stack + self.sect_ops = section_ops_inst + self.var_ops = variable_ops_inst + self.view_page_func = view_page_func + self.update_ns_sub_data_func = update_ns_sub_data_func + self.reload_ns_tree_func = reload_ns_tree_func + + def apply_diff( + self, + config_name, + config_diff, + origin_name=None, + triggers_ok=False, + is_reversed=False, + ): + """Apply a metomi.rose.config.ConfigNodeDiff object to the config.""" + state_reason_dict = { + metomi.rose.config.ConfigNode.STATE_NORMAL: {}, + metomi.rose.config.ConfigNode.STATE_USER_IGNORED: { + metomi.rose.variable.IGNORED_BY_USER: ( + metomi.rose.config_editor.IGNORED_STATUS_MACRO + ) + }, + metomi.rose.config.ConfigNode.STATE_SYST_IGNORED: { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_MACRO + ) + }, + } + nses = [] + ids = [] + # Handle added sections. + for keys, data in sorted( + config_diff.get_added(), key=lambda _: len(_[0]) + ): + value, state, comments = data + reason = state_reason_dict[state] + if len(keys) == 1: + # Section. + sect = keys[0] + ids.append(sect) + nses.append( + self.sect_ops.add_section( + config_name, + sect, + comments=comments, + ignored_reason=reason, + skip_update=True, + skip_undo=True, + ) + ) + else: + sect, opt = keys + var_id = self.util.get_id_from_section_option(sect, opt) + ids.append(var_id) + metadata = self.data.helper.get_metadata_for_config_id( + var_id, config_name + ) + variable = metomi.rose.variable.Variable(opt, value, metadata) + variable.comments = copy.deepcopy(comments) + variable.ignored_reason = copy.deepcopy(reason) + self.data.load_ns_for_node(variable, config_name) + nses.append( + self.var_ops.add_var( + variable, skip_update=True, skip_undo=True + ) + ) + + # Handle modified settings. + sections = self.data.config[config_name].sections + for keys, data in config_diff.get_modified(): + old_value, old_state, old_comments = data[0] + value, state, comments = data[1] + comments = copy.deepcopy(comments) + old_reason = state_reason_dict[old_state] + reason = copy.deepcopy(state_reason_dict[state]) + sect = keys[0] + sect_data = sections.now[sect] + opt = None + var = None + if len(keys) > 1: + opt = keys[1] + var_id = self.util.get_id_from_section_option(sect, opt) + ids.append(var_id) + var = self.data.helper.get_variable_by_id(var_id, config_name) + else: + ids.append(sect) + if comments != old_comments: + # Change the comments. + if opt is None: + # Section. + nses.append( + self.sect_ops.set_section_comments( + config_name, + sect, + comments, + skip_update=True, + skip_undo=True, + ) + ) + else: + nses.append( + self.var_ops.set_var_comments( + variable, + comments, + skip_undo=True, + skip_update=True, + ) + ) + + if opt is not None and value != old_value: + # Change the value (has to be a variable). + nses.append( + self.var_ops.set_var_value( + var, value, skip_undo=True, skip_update=True + ) + ) + + if opt is None: + ignored_changed = True + is_ignored = False + if ( + metomi.rose.variable.IGNORED_BY_USER in old_reason + and metomi.rose.variable.IGNORED_BY_USER not in reason + ): + # Enable from user-ignored. + is_ignored = False + elif ( + metomi.rose.variable.IGNORED_BY_USER not in old_reason + and metomi.rose.variable.IGNORED_BY_USER in reason + ): + # User-ignore from enabled. + is_ignored = True + elif ( + triggers_ok + and metomi.rose.variable.IGNORED_BY_SYSTEM + not in old_reason + and metomi.rose.variable.IGNORED_BY_SYSTEM in reason + ): + # Trigger-ignore. + sect_data.error.setdefault( + metomi.rose.config_editor.WARNING_TYPE_ENABLED, + metomi.rose.config_editor.IGNORED_STATUS_MACRO, + ) + is_ignored = True + elif ( + triggers_ok + and metomi.rose.variable.IGNORED_BY_SYSTEM in old_reason + and metomi.rose.variable.IGNORED_BY_SYSTEM not in reason + ): + # Enabled from trigger-ignore. + sect_data.error.setdefault( + metomi.rose.config_editor.WARNING_TYPE_TRIGGER_IGNORED, + metomi.rose.config_editor.IGNORED_STATUS_MACRO, + ) + is_ignored = False + else: + ignored_changed = False + if ignored_changed: + ignore_nses, ignore_ids = self.sect_ops.ignore_section( + config_name, + sect, + is_ignored, + override=True, + skip_update=True, + skip_undo=True, + ) + nses.extend(ignore_nses) + ids.extend(ignore_ids) + elif set(reason) != set(old_reason): + nses.append( + self.var_ops.set_var_ignored( + var, + new_reason_dict=reason, + override=True, + skip_undo=True, + skip_update=True, + ) + ) + + for keys, data in sorted( + config_diff.get_removed(), key=lambda _: -len(_[0]) + ): + # Sort so that variables are removed first. + sect = keys[0] + if len(keys) == 1: + ids.append(sect) + nses.extend( + self.sect_ops.remove_section( + config_name, sect, skip_update=True, skip_undo=True + ) + ) + else: + sect = keys[0] + opt = keys[1] + var_id = self.util.get_id_from_section_option(sect, opt) + ids.append(var_id) + var = self.data.helper.get_variable_by_id(var_id, config_name) + nses.append( + self.var_ops.remove_var( + var, skip_update=True, skip_undo=True + ) + ) + reverse_diff = config_diff.get_reversed() + if is_reversed: + action = metomi.rose.config_editor.STACK_ACTION_REVERSED + else: + action = metomi.rose.config_editor.STACK_ACTION_APPLIED + stack_item = metomi.rose.config_editor.stack.StackItem( + None, + action, + reverse_diff, + self.apply_diff, + ( + config_name, + reverse_diff, + origin_name, + triggers_ok, + not is_reversed, + ), + custom_name=origin_name, + ) + self.undo_stack.append(stack_item) + del self.redo_stack[:] + self.reload_ns_tree_func() + for ns in set(nses): + if ns is None: + # Invalid or zero-change operation, e.g. by a corrupt macro. + continue + self.sect_ops.trigger_update(ns, skip_sub_data_update=True) + self.sect_ops.trigger_info_update(ns) + self.sect_ops.trigger_update_sub_data() + self.sect_ops.trigger_update(config_name) + return ids + + def add_section_with_options( + self, config_name, new_section_name, opt_map=None + ): + """Add a section and any compulsory options. + + Any option-value pairs in the opt_map dict will also be added. + + """ + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_ADD + "-" + str(time.time()) + ) + self.sect_ops.add_section( + config_name, new_section_name, skip_update=True + ) + namespace = self.data.helper.get_default_section_namespace( + new_section_name, config_name + ) + config_data = self.data.config[config_name] + if opt_map is None: + opt_map = {} + for var in list(config_data.vars.latent.get(new_section_name, [])): + if var.name in opt_map: + var.value = opt_map.pop(var.name) + if var.name in opt_map or ( + var.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + ): + self.var_ops.add_var(var, skip_update=True) + for opt_name, value in list(opt_map.items()): + var_id = self.util.get_id_from_section_option( + new_section_name, opt_name + ) + metadata = self.data.helper.get_metadata_for_config_id( + var_id, config_name + ) + metadata["full_ns"] = namespace + flags = self.data.load_option_flags( + config_name, new_section_name, opt_name + ) + ignored_reason = {} # This may not be safe. + var = metomi.rose.variable.Variable( + opt_name, + value, + metadata, + ignored_reason, + error={}, + flags=flags, + ) + self.var_ops.add_var(var, skip_update=True) + self.reload_ns_tree_func(namespace) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + return new_section_name + + def copy_section( + self, config_name, section, new_section=None, skip_update=False + ): + """Copy a section and its options.""" + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_COPY + "-" + str(time.time()) + ) + config_data = self.data.config[config_name] + section_base = re.sub(r"(.*)\(\w+\)$", r"\1", section) + existing_sections = [] + clone_vars = [] + existing_sections = list(config_data.vars.now.keys()) + existing_sections.extend(list(config_data.sections.now.keys())) + for variable in config_data.vars.now.get(section, []): + clone_vars.append(variable.copy()) + if new_section is None: + i = 1 + new_section = section_base + "(" + str(i) + ")" + while new_section in existing_sections: + i += 1 + new_section = section_base + "(" + str(i) + ")" + new_namespace = self.sect_ops.add_section( + config_name, new_section, skip_update=skip_update + ) + if new_namespace is None: + # Add failed (section already exists). + return + for var in clone_vars: + var_id = self.util.get_id_from_section_option( + new_section, var.name + ) + metadata = self.data.helper.get_metadata_for_config_id( + var_id, config_name + ) + var.process_metadata(metadata) + var.metadata["full_ns"] = new_namespace + sorter = metomi.rose.config.sort_settings + clone_vars.sort(key=cmp_to_key(lambda v, w: sorter(v.name, w.name))) + if skip_update: + for var in clone_vars: + self.var_ops.add_var(var, skip_update=skip_update) + else: + page = self.view_page_func(new_namespace) + for var in clone_vars: + page.add_row(var) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + return new_section + + def ignore_sections( + self, + config_name, + sections, + is_ignored, + skip_update=False, + skip_sub_data_update=True, + ): + """Implement a mass user-ignore or enable of sections.""" + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_IGNORE + + "-" + + str(time.time()) + ) + nses = [] + for section in sections: + ns = self.data.helper.get_default_section_namespace( + section, config_name + ) + if ns not in nses: + nses.append(ns) + skipped_nses = self.sect_ops.ignore_section( + config_name, section, is_ignored, skip_update=True + )[0] + for ns in skipped_nses: + if ns not in nses: + nses.append(ns) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + if not skip_update: + for ns in nses: + self.sect_ops.trigger_update( + ns, skip_sub_data_update=skip_sub_data_update + ) + self.sect_ops.trigger_info_update(ns) + self.sect_ops.trigger_update(config_name) + self.update_ns_sub_data_func(config_name) + + def remove_section(self, config_name, section, skip_update=False): + """Implement a remove of a section and its options.""" + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_DELETE + + "-" + + str(time.time()) + ) + config_data = self.data.config[config_name] + variables = config_data.vars.now.get(section, []) + for variable in list(variables): + self.var_ops.remove_var(variable, skip_update=True) + self.sect_ops.remove_section( + config_name, section, skip_update=skip_update + ) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + + def rename_section( + self, config_name, section, target_section, skip_update=False + ): + """Implement a rename of a section and its options.""" + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_RENAME + + "-" + + str(time.time()) + ) + added_section = self.copy_section( + config_name, section, target_section, skip_update=skip_update + ) + if added_section is None: + # Couldn't add the target section. + return + self.remove_section(config_name, section, skip_update=skip_update) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + + def remove_sections(self, config_name, sections, skip_update=False): + """Implement a mass removal of sections.""" + start_stack_index = len(self.undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_DELETE + + "-" + + str(time.time()) + ) + nses = [] + for section in sections: + ns = self.data.helper.get_default_section_namespace( + section, config_name + ) + if ns not in nses: + nses.append(ns) + self.remove_section(config_name, section, skip_update=True) + for stack_item in self.undo_stack[start_stack_index:]: + stack_item.group = group + if not skip_update: + self.reload_ns_tree_func(only_this_config=config_name) + + def get_sub_ops_for_namespace(self, namespace): + """Return data functions for summary (sub) data panels.""" + if not namespace.startswith("/"): + namespace = "/" + namespace + config_name = self.util.split_full_ns(self.data, namespace)[0] + return SubDataOperations( + config_name, + self.add_section_with_options, + self.copy_section, + self.sect_ops.ignore_section, + self.ignore_sections, + self.remove_section, + self.remove_sections, + get_var_id_values_func=( + self.data.helper.get_sub_data_var_id_value_map + ), + ) + + +class SubDataOperations(object): + """Class to hold a selected set of functions.""" + + def __init__( + self, + config_name, + add_section_func, + clone_section_func, + ignore_section_func, + ignore_sections_func, + remove_section_func, + remove_sections_func, + get_var_id_values_func, + ): + self.config_name = config_name + self._add_section_func = add_section_func + self._clone_section_func = clone_section_func + self._ignore_section_func = ignore_section_func + self._ignore_sections_func = ignore_sections_func + self._remove_section_func = remove_section_func + self._remove_sections_func = remove_sections_func + self._get_var_id_values_func = get_var_id_values_func + + def add_section(self, new_section_name, opt_map=None): + """Add a new section, complete with any compulsory variables.""" + return self._add_section_func( + self.config_name, new_section_name, opt_map=opt_map + ) + + def clone_section(self, clone_section_name): + """Copy a (duplicate) section and all its options.""" + return self._clone_section_func(self.config_name, clone_section_name) + + def ignore_section(self, ignore_section_name, is_ignored): + """User-ignore or enable a section.""" + return self._ignore_section_func( + self.config_name, ignore_section_name, is_ignored + ) + + def ignore_sections( + self, ignore_sections_list, is_ignored, skip_sub_data_update=True + ): + """User-ignore or enable a list of sections.""" + return self._ignore_sections_func( + self.config_name, + ignore_sections_list, + is_ignored, + skip_sub_data_update=skip_sub_data_update, + ) + + def remove_section(self, remove_section_name): + """Remove a section and all its options.""" + return self._remove_section_func(self.config_name, remove_section_name) + + def remove_sections(self, remove_sections_list): + """Remove a list of sections and all their options.""" + return self._remove_sections_func( + self.config_name, remove_sections_list + ) + + def get_var_id_values(self): + """Return a map of all var id values.""" + return self._get_var_id_values_func(self.config_name) diff --git a/metomi/rose/config_editor/ops/section.py b/metomi/rose/config_editor/ops/section.py new file mode 100644 index 0000000000..481463aa07 --- /dev/null +++ b/metomi/rose/config_editor/ops/section.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This module deals with section-specific actions. + +The methods of SectionOperations are the only ways that section data +objects should be interacted with. There are also some utility methods. + +""" + +import copy + +import gi + +gi.require_version("Gtk", "3.0") + +import metomi.rose.config_editor.stack +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util + + +class SectionOperations(object): + """A class to hold functions that act on sections and their storage.""" + + def __init__( + self, + data, + util, + reporter, + undo_stack, + redo_stack, + check_cannot_enable_func=metomi.rose.config_editor.false_function, + update_ns_func=metomi.rose.config_editor.false_function, + update_sub_data_func=metomi.rose.config_editor.false_function, + update_info_func=metomi.rose.config_editor.false_function, + update_comments_func=metomi.rose.config_editor.false_function, + update_tree_func=metomi.rose.config_editor.false_function, + search_id_func=metomi.rose.config_editor.false_function, + view_page_func=metomi.rose.config_editor.false_function, + kill_page_func=metomi.rose.config_editor.false_function, + ): + self.__data = data + self.__util = util + self.__reporter = reporter + self.__undo_stack = undo_stack + self.__redo_stack = redo_stack + self.check_cannot_enable_setting = check_cannot_enable_func + self.trigger_update = update_ns_func + self.trigger_update_sub_data = update_sub_data_func + self.trigger_info_update = update_info_func + self.trigger_reload_tree = update_tree_func + self.search_id_func = search_id_func + self.view_page_func = view_page_func + self.kill_page_func = kill_page_func + + def add_section( + self, + config_name, + section, + skip_update=False, + page_launch=False, + comments=None, + ignored_reason=None, + skip_undo=False, + ): + """Add a section to this configuration.""" + config_data = self.__data.config[config_name] + new_section_data = None + if not section or section in config_data.sections.now: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.ERROR_SECTION_ADD.format(section), + title=metomi.rose.config_editor.ERROR_SECTION_ADD_TITLE, + modal=False, + ) + return + if section in config_data.sections.latent: + new_section_data = config_data.sections.latent.pop(section) + else: + metadata = self.__data.helper.get_metadata_for_config_id( + section, config_name + ) + new_section_data = metomi.rose.section.Section( + section, [], metadata + ) + if comments is not None: + new_section_data.comments = copy.deepcopy(comments) + if ignored_reason is not None: + new_section_data.ignored_reason = copy.deepcopy(ignored_reason) + config_data.sections.now.update({section: new_section_data}) + self.__data.add_section_to_config(section, config_name) + self.__data.load_ns_for_node(new_section_data, config_name) + self.__data.load_file_metadata(config_name, section) + self.__data.load_vars_from_config( + config_name, only_this_section=section, update=True + ) + self.__data.load_node_namespaces( + config_name, only_this_section=section + ) + metadata = self.__data.helper.get_metadata_for_config_id( + section, config_name + ) + new_section_data.process_metadata(metadata) + ns = new_section_data.metadata["full_ns"] + if not skip_update: + self.trigger_reload_tree(ns) + if metomi.rose.META_PROP_DUPLICATE in metadata: + self.__data.load_namespace_has_sub_data(config_name) + if not skip_undo: + copy_section_data = new_section_data.copy() + stack_item = metomi.rose.config_editor.stack.StackItem( + ns, + metomi.rose.config_editor.STACK_ACTION_ADDED, + copy_section_data, + self.remove_section, + (config_name, section, skip_update), + ) + self.__undo_stack.append(stack_item) + del self.__redo_stack[:] + if page_launch and not skip_update: + self.view_page_func(ns) + if not skip_update: + self.trigger_update(ns) + return ns + + def ignore_section( + self, + config_name, + section, + is_ignored, + override=False, + skip_update=False, + skip_undo=False, + ): + """Ignore or enable a section for this configuration. + + Returns a list of namespaces that need further updates. This is + empty if skip_update is False. + + """ + config_data = self.__data.config[config_name] + sect_data = config_data.sections.now[section] + nses_to_do = [sect_data.metadata["full_ns"]] + ids_to_do = [section] + + if is_ignored: + # User-ignore request for this section. + # The section must be enabled and optional. + if not override and ( + sect_data.ignored_reason + or sect_data.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + ): + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.WARNING_CANNOT_USER_IGNORE.format( # noqa: E501 + section + ), + metomi.rose.config_editor.WARNING_CANNOT_IGNORE_TITLE, + ) + return [], [] + for error in [ + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED, + metomi.rose.config_editor.WARNING_TYPE_ENABLED, + ]: + if error in sect_data.error: + sect_data.ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_MANUAL + ) + } + ) + sect_data.error.pop(error) + break + else: + sect_data.ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_USER: ( + metomi.rose.config_editor.IGNORED_STATUS_MANUAL + ) + } + ) + action = metomi.rose.config_editor.STACK_ACTION_IGNORED + else: + # Enable request for this section. + # The section must not be justifiably triggered ignored. + ign_errors = [ + e + for e in metomi.rose.config_editor.WARNING_TYPES_IGNORE + if e != metomi.rose.config_editor.WARNING_TYPE_ENABLED + ] + my_errors = list(sect_data.error.keys()) + if ( + not override + and ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in sect_data.ignored_reason + ) + and all([e not in my_errors for e in ign_errors]) + and self.check_cannot_enable_setting(config_name, section) + ): + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + metomi.rose.config_editor.WARNING_CANNOT_ENABLE.format( + section + ), + metomi.rose.config_editor.WARNING_CANNOT_ENABLE_TITLE, + ) + return [], [] + sect_data.ignored_reason.clear() + for error in ign_errors: + if error in my_errors: + sect_data.error.pop(error) + action = metomi.rose.config_editor.STACK_ACTION_ENABLED + + ns = sect_data.metadata["full_ns"] + copy_sect_data = sect_data.copy() + if not skip_undo: + stack_item = metomi.rose.config_editor.stack.StackItem( + ns, + action, + copy_sect_data, + self.ignore_section, + (config_name, section, not is_ignored, True), + ) + self.__undo_stack.append(stack_item) + del self.__redo_stack[:] + for var in config_data.vars.now.get( + section, [] + ) + config_data.vars.latent.get(section, []): + self.trigger_info_update(var) + if var.metadata["full_ns"] not in nses_to_do: + nses_to_do.append(var.metadata["full_ns"]) + ids_to_do.append(var.metadata["id"]) + if is_ignored: + var.ignored_reason.update( + { + metomi.rose.variable.IGNORED_BY_SECTION: ( + metomi.rose.config_editor.IGNORED_STATUS_MANUAL + ) + } + ) + elif metomi.rose.variable.IGNORED_BY_SECTION in var.ignored_reason: + var.ignored_reason.pop(metomi.rose.variable.IGNORED_BY_SECTION) + else: + continue + if skip_update: + return nses_to_do, ids_to_do + for ns in nses_to_do: + self.trigger_update(ns) + self.trigger_info_update(ns) + self.trigger_update(config_name) + return [], [] + + def remove_section( + self, config_name, section, skip_update=False, skip_undo=False + ): + """Remove a section from this configuration.""" + config_data = self.__data.config[config_name] + old_section_data = config_data.sections.now.pop(section) + config_data.sections.latent.update({section: old_section_data}) + if section in config_data.vars.now: + config_data.vars.now.pop(section) + namespace = old_section_data.metadata["full_ns"] + ns_list = [namespace] + for ns, values in list(self.__data.namespace_meta_lookup.items()): + sections = values.get("sections") + if sections == [section]: + if ns not in ns_list: + ns_list.append(ns) + if not skip_undo: + stack_item = metomi.rose.config_editor.stack.StackItem( + namespace, + metomi.rose.config_editor.STACK_ACTION_REMOVED, + old_section_data.copy(), + self.add_section, + (config_name, section, skip_update), + ) + for ns in ns_list: + self.kill_page_func(ns) + self.__undo_stack.append(stack_item) + del self.__redo_stack[:] + if not skip_update: + self.trigger_reload_tree(only_this_namespace=namespace) + return ns_list + + def set_section_comments( + self, + config_name, + section, + comments, + skip_update=False, + skip_undo=False, + ): + """Change the comments field for the section object.""" + config_data = self.__data.config[config_name] + sect_data = config_data.sections.now[section] + old_sect_data = sect_data.copy() + last_comments = old_sect_data.comments + sect_data.comments = comments + if not skip_undo: + ns = sect_data.metadata["full_ns"] + stack_item = metomi.rose.config_editor.stack.StackItem( + ns, + metomi.rose.config_editor.STACK_ACTION_CHANGED_COMMENTS, + old_sect_data, + self.set_section_comments, + (config_name, section, last_comments), + ) + self.__undo_stack.append(stack_item) + del self.__redo_stack[:] + if not skip_update: + self.trigger_update(ns) + return ns + + def is_section_modified(self, section_object): + """Check against the last saved section object reference.""" + section = section_object.metadata["id"] + namespace = section_object.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + config_data = self.__data.config[config_name] + this_section = config_data.sections.now.get(section) + save_section = config_data.sections.save.get(section) + if this_section is None: + # Ghost variable, check absence from saved list. + if save_section is not None: + return True + else: + # Real variable, check value and presence in saved list. + if save_section is None: + return True + return this_section.to_hashable() != this_section.to_hashable() + + def get_section_changes(self, section_object): + """Return text describing changes since the last save.""" + section = section_object.metadata["id"] + namespace = section_object.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + config_data = self.__data.config[config_name] + this_section = config_data.sections.now.get(section) + save_section = config_data.sections.save.get(section) + if this_section is None: + if save_section is not None: + return metomi.rose.config_editor.KEY_TIP_MISSING + # Ignore both-missing scenarios (no actual diff in output). + return "" + if save_section is None: + return metomi.rose.config_editor.KEY_TIP_ADDED + if this_section.to_hashable() == save_section.to_hashable(): + return "" + if this_section.comments != save_section.comments: + return metomi.rose.config_editor.KEY_TIP_CHANGED_COMMENTS + # The difference must now be in the ignored state. + if ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in this_section.ignored_reason + ): + return metomi.rose.config_editor.KEY_TIP_TRIGGER_IGNORED + if metomi.rose.variable.IGNORED_BY_USER in this_section.ignored_reason: + return metomi.rose.config_editor.KEY_TIP_USER_IGNORED + return metomi.rose.config_editor.KEY_TIP_ENABLED + + def get_ns_metadata_files(self, namespace): + """Retrieve filenames within the metadata for this namespace.""" + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + return self.__data.config[config_name].meta_files diff --git a/metomi/rose/config_editor/ops/variable.py b/metomi/rose/config_editor/ops/variable.py new file mode 100644 index 0000000000..1a5554da1e --- /dev/null +++ b/metomi/rose/config_editor/ops/variable.py @@ -0,0 +1,503 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This module deals with variable actions. + +The methods of VariableOperations are the only ways that variable data +objects should be interacted with (adding, removing, changing value, +etc). There are also some utility methods. + +""" +import copy +import time +import webbrowser + +import metomi.rose.variable +import metomi.rose.config_editor +import metomi.rose.config_editor.stack + + +class VariableOperations(object): + """A class to hold functions that act on variables and their storage.""" + + def __init__( + self, + data, + util, + reporter, + undo_stack, + redo_stack, + add_section_func, + check_cannot_enable_func=metomi.rose.config_editor.false_function, + update_ns_func=metomi.rose.config_editor.false_function, + ignore_update_func=metomi.rose.config_editor.false_function, + search_id_func=metomi.rose.config_editor.false_function, + ): + self.__data = data + self.__util = util + self.__reporter = reporter + self.__undo_stack = undo_stack + self.__redo_stack = redo_stack + self.__add_section_func = add_section_func + self.check_cannot_enable_setting = check_cannot_enable_func + self.trigger_update = update_ns_func + self.trigger_ignored_update = ignore_update_func + self.search_id_func = search_id_func + + def _get_proper_variable(self, possible_copy_variable): + # Some variables are just copies, and changes to them + # won't affect anything. We need to look up the 'real' variable. + namespace = possible_copy_variable.metadata.get("full_ns") + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + var_id = possible_copy_variable.metadata["id"] + return self.__data.helper.get_ns_variable(var_id, config_name) + + def add_var(self, variable, skip_update=False, skip_undo=False): + """Add a variable to the internal list.""" + namespace = variable.metadata.get("full_ns") + var_id = variable.metadata["id"] + sect, opt = self.__util.get_section_option_from_id(var_id) + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + config_data = self.__data.config[config_name] + old_metadata = copy.deepcopy(variable.metadata) + flags = self.__data.load_option_flags(config_name, sect, opt) + variable.flags.update(flags) + metadata = self.__data.helper.get_metadata_for_config_id( + var_id, config_name + ) + variable.process_metadata(metadata) + variable.metadata.update(old_metadata) + variables = config_data.vars.now.get(sect, []) + copy_var = variable.copy() + v_id = variable.metadata.get("id") + if v_id in [v.metadata.get("id") for v in variables]: + # This is the case of adding a blank variable and + # renaming it to an existing variable's name. + # At the moment, assume this should just be skipped. + pass + else: + group = None + if sect not in config_data.sections.now: + start_stack_index = len(self.__undo_stack) + group = ( + metomi.rose.config_editor.STACK_GROUP_ADD + + "-" + + str(time.time()) + ) + self.__add_section_func(config_name, sect) + for item in self.__undo_stack[start_stack_index:]: + item.group = group + latent_variables = config_data.vars.latent.get(sect, []) + for latent_var in list(latent_variables): + if latent_var.metadata["id"] == v_id: + latent_variables.remove(latent_var) + config_data.vars.now.setdefault(sect, []) + config_data.vars.now[sect].append(variable) + if not skip_undo: + self.__undo_stack.append( + metomi.rose.config_editor.stack.StackItem( + variable.metadata["full_ns"], + metomi.rose.config_editor.STACK_ACTION_ADDED, + copy_var, + self.remove_var, + [copy_var, skip_update], + group=group, + ) + ) + del self.__redo_stack[:] + if not skip_update: + self.trigger_update(variable.metadata["full_ns"]) + return variable.metadata["full_ns"] + + def remove_var(self, variable, skip_update=False, skip_undo=False): + """Remove the variable entry from the internal lists.""" + variable = self._get_proper_variable(variable) + variable.error = {} # Kill any metadata errors before removing. + namespace = variable.metadata.get("full_ns") + var_id = variable.metadata["id"] + sect = self.__util.get_section_option_from_id(var_id)[0] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + config_data = self.__data.config[config_name] + variables = config_data.vars.now.get(sect, []) + latent_variables = config_data.vars.latent.get(sect, []) + if variable in latent_variables: + latent_variables.remove(variable) + if not config_data.vars.latent[sect]: + config_data.vars.latent.pop(sect) + return None + if variable in variables: + variables.remove(variable) + if not config_data.vars.now[sect]: + config_data.vars.now.pop(sect) + if variable.name: + config_data.vars.latent.setdefault(sect, []) + config_data.vars.latent[sect].append(variable) + if not skip_undo: + copy_var = variable.copy() + self.__undo_stack.append( + metomi.rose.config_editor.stack.StackItem( + variable.metadata["full_ns"], + metomi.rose.config_editor.STACK_ACTION_REMOVED, + copy_var, + self.add_var, + [copy_var, skip_update], + ) + ) + del self.__redo_stack[:] + if not skip_update: + self.trigger_update(variable.metadata["full_ns"]) + return variable.metadata["full_ns"] + + def fix_var_ignored(self, variable): + """Fix any variable ignore state errors.""" + ignored_reasons = list(variable.ignored_reason.keys()) + new_reason_dict = {} # Enable, by default. + old_reason = variable.ignored_reason.copy() + if metomi.rose.variable.IGNORED_BY_SECTION in old_reason: + # Preserve section-ignored status. + new_reason_dict.setdefault( + metomi.rose.variable.IGNORED_BY_SECTION, + old_reason[metomi.rose.variable.IGNORED_BY_SECTION], + ) + if metomi.rose.variable.IGNORED_BY_SYSTEM in ignored_reasons: + # Doc table I_t + if ( + metomi.rose.config_editor.WARNING_TYPE_ENABLED + in variable.error + ): + # Enable new_reason_dict. + # Doc table I_t -> E + pass + if ( + metomi.rose.config_editor.WARNING_TYPE_NOT_TRIGGER + in variable.error + ): + pass + elif metomi.rose.variable.IGNORED_BY_USER in ignored_reasons: + # Doc table I_u + if ( + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED + in variable.error + ): + # Enable new_reason_dict. + # Doc table I_u -> I_t -> *, + # I_u -> E -> compulsory, + # I_u -> not trigger -> compulsory + pass + else: + # Doc table E + if ( + metomi.rose.config_editor.WARNING_TYPE_ENABLED + in variable.error + ): + # Doc table E -> I_t -> * + new_reason_dict = { + metomi.rose.variable.IGNORED_BY_SYSTEM: ( + metomi.rose.config_editor.IGNORED_STATUS_MANUAL + ) + } + self.set_var_ignored(variable, new_reason_dict) + + def set_var_ignored( + self, + variable, + new_reason_dict=None, + override=False, + skip_update=False, + skip_undo=False, + ): + """Set the ignored flag data for the variable. + + new_reason_dict replaces the variable.ignored_reason attribute, + except for the metomi.rose.variable.IGNORED_BY_SECTION key. + + """ + if new_reason_dict is None: + new_reason_dict = {} + variable = self._get_proper_variable(variable) + old_reason = variable.ignored_reason.copy() + if metomi.rose.variable.IGNORED_BY_SECTION in old_reason: + new_reason_dict.setdefault( + metomi.rose.variable.IGNORED_BY_SECTION, + old_reason[metomi.rose.variable.IGNORED_BY_SECTION], + ) + if metomi.rose.variable.IGNORED_BY_SECTION not in old_reason: + if metomi.rose.variable.IGNORED_BY_SECTION in new_reason_dict: + new_reason_dict.pop(metomi.rose.variable.IGNORED_BY_SECTION) + variable.ignored_reason = new_reason_dict.copy() + if not set(old_reason.keys()) ^ set(new_reason_dict.keys()): + # No practical difference, so don't do anything. + return None + # Protect against user-enabling of triggered ignored. + if ( + not override + and metomi.rose.variable.IGNORED_BY_SYSTEM in old_reason + and metomi.rose.variable.IGNORED_BY_SYSTEM not in new_reason_dict + ): + if ( + metomi.rose.config_editor.WARNING_TYPE_NOT_TRIGGER + in variable.error + ): + variable.error.pop( + metomi.rose.config_editor.WARNING_TYPE_NOT_TRIGGER + ) + my_ignored_keys = list(variable.ignored_reason.keys()) + if metomi.rose.variable.IGNORED_BY_SECTION in my_ignored_keys: + my_ignored_keys.remove(metomi.rose.variable.IGNORED_BY_SECTION) + old_ignored_keys = list(old_reason.keys()) + if metomi.rose.variable.IGNORED_BY_SECTION in old_ignored_keys: + old_ignored_keys.remove(metomi.rose.variable.IGNORED_BY_SECTION) + if len(my_ignored_keys) > len(old_ignored_keys): + action_text = metomi.rose.config_editor.STACK_ACTION_IGNORED + if ( + not old_ignored_keys + and metomi.rose.config_editor.WARNING_TYPE_ENABLED + in variable.error + ): + variable.error.pop( + metomi.rose.config_editor.WARNING_TYPE_ENABLED + ) + else: + action_text = metomi.rose.config_editor.STACK_ACTION_ENABLED + if not my_ignored_keys: + for err_type in metomi.rose.config_editor.WARNING_TYPES_IGNORE: + if err_type in variable.error: + variable.error.pop(err_type) + if not skip_undo: + copy_var = variable.copy() + self.__undo_stack.append( + metomi.rose.config_editor.stack.StackItem( + variable.metadata["full_ns"], + action_text, + copy_var, + self.set_var_ignored, + [copy_var, old_reason, True], + ) + ) + del self.__redo_stack[:] + self.trigger_ignored_update(variable) + if not skip_update: + self.trigger_update(variable.metadata["full_ns"]) + return variable.metadata["full_ns"] + + def set_var_value( + self, variable, new_value, skip_update=False, skip_undo=False + ): + """Set the value of the variable.""" + variable = self._get_proper_variable(variable) + if variable.value == new_value: + # A bad valuewidget setter. + return None + variable.old_value = variable.value + variable.value = new_value + if not skip_undo: + copy_var = variable.copy() + self.__undo_stack.append( + metomi.rose.config_editor.stack.StackItem( + variable.metadata["full_ns"], + metomi.rose.config_editor.STACK_ACTION_CHANGED, + copy_var, + self.set_var_value, + [copy_var, copy_var.old_value], + ) + ) + del self.__redo_stack[:] + if not skip_update: + self.trigger_update(variable.metadata["full_ns"]) + return variable.metadata["full_ns"] + + def set_var_comments( + self, variable, comments, skip_update=False, skip_undo=False + ): + """Set the comments field for the variable.""" + variable = self._get_proper_variable(variable) + copy_variable = variable.copy() + old_comments = copy_variable.comments + variable.comments = comments + if not skip_undo: + self.__undo_stack.append( + metomi.rose.config_editor.stack.StackItem( + variable.metadata["full_ns"], + metomi.rose.config_editor.STACK_ACTION_CHANGED_COMMENTS, + copy_variable, + self.set_var_comments, + [copy_variable, old_comments], + ) + ) + del self.__redo_stack[:] + if not skip_update: + self.trigger_update(variable.metadata["full_ns"]) + return variable.metadata["full_ns"] + + def get_var_original_comments(self, variable): + """Get the original comments, if any.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + save_var = self.__data.helper.get_variable_by_id( + var_id, config_name, save=True + ) + if save_var is None: + return None + return save_var.comments + + def get_var_original_ignore(self, variable): + """Get the original value, if any.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + save_var = self.__data.helper.get_variable_by_id( + var_id, config_name, save=True + ) + if save_var is None: + return None + return save_var.ignored_reason + + def get_var_original_value(self, variable): + """Get the original value, if any.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + save_variable = self.__data.helper.get_variable_by_id( + var_id, config_name, save=True + ) + if save_variable is None: + return None + return save_variable.value + + def is_var_modified(self, variable): + """Check against the last saved variable reference.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + this_variable = self.__data.helper.get_variable_by_id( + var_id, config_name + ) + save_variable = self.__data.helper.get_variable_by_id( + var_id, config_name, save=True + ) + if this_variable is None: + # Ghost variable, check absence from saved list. + if save_variable is not None: + return True + else: + # Real variable, check value and presence in saved list. + if save_variable is None: + return True + return this_variable.to_hashable() != save_variable.to_hashable() + + def is_var_added(self, variable): + """Check if missing from the saved variables list.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + save_variable = self.__data.helper.get_variable_by_id( + var_id, config_name, save=True + ) + return save_variable is None + + def is_var_ghost(self, variable): + """Check if the variable is a latent variable.""" + var_id = variable.metadata["id"] + namespace = variable.metadata["full_ns"] + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + this_variable = self.__data.helper.get_variable_by_id( + var_id, config_name + ) + return this_variable is None + + def get_var_changes(self, variable): + """Return a description of any changed status the variable has.""" + if self.is_var_modified(variable): + if self.is_var_added(variable): + return metomi.rose.config_editor.KEY_TIP_ADDED + if self.is_var_ghost(variable): + return metomi.rose.config_editor.KEY_TIP_MISSING + old_value = self.get_var_original_value(variable) + if variable.value != self.get_var_original_value(variable): + return metomi.rose.config_editor.KEY_TIP_CHANGED.format( + old_value + ) + if self.get_var_original_comments(variable) != variable.comments: + return metomi.rose.config_editor.KEY_TIP_CHANGED_COMMENTS + if not variable.ignored_reason: + return metomi.rose.config_editor.KEY_TIP_ENABLED + old_ignore = self.get_var_original_ignore(variable) + if len(old_ignore) > len(variable.ignored_reason): + return metomi.rose.config_editor.KEY_TIP_ENABLED + if ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in variable.ignored_reason + and metomi.rose.variable.IGNORED_BY_SYSTEM not in old_ignore + ): + return metomi.rose.config_editor.KEY_TIP_TRIGGER_IGNORED + if ( + metomi.rose.variable.IGNORED_BY_USER in variable.ignored_reason + and metomi.rose.variable.IGNORED_BY_USER not in old_ignore + ): + return metomi.rose.config_editor.KEY_TIP_USER_IGNORED + if ( + metomi.rose.variable.IGNORED_BY_SECTION + in variable.ignored_reason + and metomi.rose.variable.IGNORED_BY_SECTION not in old_ignore + ): + return metomi.rose.config_editor.KEY_TIP_SECTION_IGNORED + return metomi.rose.config_editor.KEY_TIP_ENABLED + return "" + + def launch_url(self, variable): + """Determine and launch the variable help URL in a web browser.""" + if metomi.rose.META_PROP_URL not in variable.metadata: + return + url = variable.metadata[metomi.rose.META_PROP_URL] + if metomi.rose.variable.REC_FULL_URL.match(url): + # It is a proper URL by itself - launch it. + return self._launch_url(url) + # Must be a partial URL (e.g. '#foo') - try to prefix a parent URL. + ns_url = self.__data.helper.get_ns_url_for_variable(variable) + if ns_url: + return self._launch_url(ns_url + url) + return self._launch_url(url) + + def _launch_url(self, url): + """Actually launch a URL.""" + try: + webbrowser.open(url) + except webbrowser.Error as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + + def search_for_var(self, config_name_or_namespace, setting_id): + """Launch a search for a setting or variable id.""" + config_name = self.__util.split_full_ns( + self.__data, config_name_or_namespace + )[0] + self.search_id_func(config_name, setting_id) + + def get_ns_metadata_files(self, namespace): + """Retrieve filenames within the metadata for this namespace.""" + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + return self.__data.config[config_name].meta_files + + def get_sections(self, namespace): + """Retrieve all real sections (empty or not) for this ns's config.""" + config_name = self.__util.split_full_ns(self.__data, namespace)[0] + section_objects = self.__data.config[config_name].sections.get_all( + skip_latent=True + ) + return [_.name for _ in section_objects] diff --git a/metomi/rose/config_editor/page.py b/metomi/rose/config_editor/page.py new file mode 100644 index 0000000000..68d66cb078 --- /dev/null +++ b/metomi/rose/config_editor/page.py @@ -0,0 +1,1414 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re +import time +import webbrowser + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +from gi.repository import Pango + +import metomi.rose.config_editor.panelwidget +import metomi.rose.config_editor.pagewidget +import metomi.rose.config_editor.stack +import metomi.rose.config_editor.util +import metomi.rose.config_editor.variable +import metomi.rose.formats +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util +import metomi.rose.resource +import metomi.rose.variable + +from functools import cmp_to_key + + +class ConfigPage(Gtk.Box): + """Returns a container for a tab.""" + + def __init__( + self, + page_metadata, + config_data, + ghost_data, + section_ops, + variable_ops, + sections, + latent_sections, + get_formats_func, + reporter, + directory=None, + sub_data=None, + sub_ops=None, + launch_info_func=None, + launch_edit_func=None, + launch_macro_func=None, + ): + super(ConfigPage, self).__init__( + homogeneous=False, orientation=Gtk.Orientation.VERTICAL + ) + self.namespace = page_metadata.get("namespace") + self.ns_is_default = page_metadata.get("ns_is_default") + self.config_name = page_metadata.get("config_name") + self.label = page_metadata.get("label") + self.description = page_metadata.get("description") + self.help = page_metadata.get("help") + self.url = page_metadata.get("url") + self.see_also = page_metadata.get("see_also") + self.custom_macros = page_metadata.get("macro", {}) + self.custom_widget = page_metadata.get("widget") + self.custom_sub_widget = page_metadata.get("widget_sub_ns") + self.show_modes = page_metadata.get("show_modes") + self.is_duplicate = ( + page_metadata.get("duplicate") == metomi.rose.META_PROP_VALUE_TRUE + ) + self.section = None + if sections: + self.section = sections[0] + self.sections = sections + self.latent_sections = latent_sections + self.icon_path = page_metadata.get("icon") + self.reporter = reporter + self.directory = directory + self.sub_data = sub_data + self.sub_ops = sub_ops + self.launch_info = launch_info_func + self.launch_edit = launch_edit_func + self._launch_macro_func = launch_macro_func + namespaces = self.namespace.strip("/").split("/") + namespaces.reverse() + self.info = "" + if self.description is None: + self.info = " - ".join(namespaces[:-1]) + else: + if self.description != "": + self.info = self.description + "\n" + self.info += " - ".join(namespaces[:-1]) + if self.see_also != "": + self.info += "\n => " + self.see_also + self.panel_data = config_data + self.ghost_data = ghost_data + self.section_ops = section_ops + self.variable_ops = variable_ops + self.trigger_ask_for_config_keys = lambda: get_formats_func( + self.config_name + ) + self.sort_data() + self.sort_data(ghost=True) + self._last_info_labels = None + self.generate_main_container() + self.get_page() + self.update_ignored(no_refresh=True) + + def get_page(self): + """Generate a container of widgets for page content and a label.""" + self.labelwidget = self.get_label_widget() + self.scrolled_main_window = Gtk.ScrolledWindow() + self.scrolled_main_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + self.scrolled_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.scrolled_vbox.show() + self.scrolled_main_window.add_with_viewport(self.scrolled_vbox) + self.scrolled_main_window.get_child().set_shadow_type( + Gtk.ShadowType.NONE + ) + self.scrolled_main_window.set_margin_start( + metomi.rose.config_editor.SPACING_SUB_PAGE + ) # left + self.scrolled_main_window.set_margin_end( + metomi.rose.config_editor.SPACING_SUB_PAGE + ) # right + self.scrolled_vbox.pack_start( + self.main_container, expand=False, fill=True, padding=0 + ) + self.scrolled_main_window.show() + self.main_vpaned = Gtk.VPaned() + self.info_panel = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, homogeneous=False + ) + self.info_panel.show() + self.update_info() + second_panel = None + if self.namespace == self.config_name and self.directory is not None: + self.generate_filesystem_panel() + second_panel = self.filesystem_panel + elif self.sub_data is not None: + self.generate_sub_data_panel() + second_panel = self.sub_data_panel + self.vpaned = Gtk.VPaned() + if self.panel_data: + self.vpaned.pack1( + self.scrolled_main_window, resize=True, shrink=True + ) + if second_panel is not None: + self.vpaned.pack2(second_panel, resize=False, shrink=True) + elif second_panel is not None: + self.vpaned.pack1( + self.scrolled_main_window, resize=False, shrink=True + ) + self.vpaned.pack2(second_panel, resize=True, shrink=True) + self.vpaned.set_position( + metomi.rose.config_editor.FILE_PANEL_EXPAND + ) + else: + self.vpaned.pack1( + self.scrolled_main_window, resize=True, shrink=True + ) + self.vpaned.show() + self.main_vpaned.pack2(self.vpaned) + self.main_vpaned.show() + self.pack_start(self.main_vpaned, expand=True, fill=True, padding=0) + self.show() + self.scroll_vadj = self.scrolled_main_window.get_vadjustment() + self.scrolled_main_window.connect( + "button-press-event", self._handle_click_main_window + ) + + def _handle_click_main_window(self, widget, event): + if event.button != 3: + return False + self.launch_add_menu(event) + return False + + def get_label_widget(self, is_detached=False): + """Return a container of widgets for the notebook tab label.""" + if is_detached: + location = self.config_name.lstrip("/").split("/") + location.reverse() + label = Gtk.Label(label=" - ".join([self.label] + location)) + self.is_detached = True + else: + label = Gtk.Label(label=self.label) + self.is_detached = False + label.show() + label_event_box = Gtk.EventBox() + label_event_box.add(label) + label_event_box.show() + if self.help or self.url: + label_event_box.connect( + "enter-notify-event", self._handle_enter_label + ) + label_event_box.connect( + "leave-notify-event", self._handle_leave_label + ) + label_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, homogeneous=False + ) + if self.icon_path is not None: + self.label_icon = Gtk.Image() + self.label_icon.set_from_file(self.icon_path) + self.label_icon.show() + label_box.pack_start( + self.label_icon, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + close_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_CLOSE, size=Gtk.IconSize.MENU, as_tool=True + ) + Gtk.Widget.set_name(close_button, "page-tab-button") + + label_box.pack_start( + label_event_box, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + if not is_detached: + label_box.pack_end( + close_button, expand=False, fill=False, padding=0 + ) + label_box.show() + event_box = Gtk.EventBox() + event_box.add(label_box) + close_button.connect("released", lambda b: self.close_self()) + event_box.connect("button_press_event", self._handle_click_tab) + event_box.show() + if self.info is not None: + event_box.connect("enter-notify-event", self._set_tab_tooltip) + return event_box + + def _handle_enter_label(self, label_event_box, event=None): + label = label_event_box.get_child() + att_list = label.get_attributes() + if att_list is None: + att_list = Pango.AttrList() + att_list.insert(Pango.attr_underline_new(Pango.Underline.SINGLE)) + label.set_attributes(att_list) + + def _handle_leave_label(self, label_event_box, event=None): + label = label_event_box.get_child() + att_list = label.get_attributes() + if att_list is None: + att_list = Pango.AttrList() + att_list = att_list.filter( + lambda a: a.klass.type != Pango.AttrType.UNDERLINE + ) + if att_list is None: + # This is messy but necessary. + att_list = Pango.AttrList() + label.set_attributes(att_list) + + def _set_tab_tooltip(self, event_box, event): + tip_text = "" + if self.info is not None: + tip_text += self.info + if self.section is not None: + comment_format = metomi.rose.config_editor.VAR_COMMENT_TIP.format + for comment_line in self.section.comments: + tip_text += "\n" + comment_format(comment_line) + event_box.set_tooltip_text(tip_text) + + def _handle_click_tab(self, event_widget, event): + if event.button == 3: + return self.launch_tab_menu(event) + if self.main_vpaned.get_mapped(): + if self.help: + return self.launch_help() + if self.url: + return self.launch_url() + + def launch_tab_menu(self, event): + """Open a popup menu for the tab, if right clicked.""" + ui_config_string_start = """ """ + ui_config_string_end = """ """ + if not self.is_detached: + ui_config_string_start += """ + """ + close_string = """ + """ + ui_config_string_end = close_string + ui_config_string_end + ui_config_string_start += """ + """ + actions = [ + ( + "Open", + Gtk.STOCK_NEW, + metomi.rose.config_editor.TAB_MENU_OPEN_NEW, + ), + ("Info", Gtk.STOCK_INFO, metomi.rose.config_editor.TAB_MENU_INFO), + ("Edit", Gtk.STOCK_EDIT, metomi.rose.config_editor.TAB_MENU_EDIT), + ("Help", Gtk.STOCK_HELP, metomi.rose.config_editor.TAB_MENU_HELP), + ( + "Web_Help", + Gtk.STOCK_HOME, + metomi.rose.config_editor.TAB_MENU_WEB_HELP, + ), + ( + "Close", + Gtk.STOCK_CLOSE, + metomi.rose.config_editor.TAB_MENU_CLOSE, + ), + ] + if self.help is not None: + help_string = """ + """ + ui_config_string_end = help_string + ui_config_string_end + if self.url is not None: + url_string = """ + """ + ui_config_string_end = url_string + ui_config_string_end + + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string( + ui_config_string_start + ui_config_string_end + ) + if not self.is_detached: + window_item = uimanager.get_widget("/Popup/Open") + window_item.connect("activate", self.trigger_tab_detach) + close_item = uimanager.get_widget("/Popup/Close") + close_item.connect("activate", lambda b: self.close_self()) + edit_item = uimanager.get_widget("/Popup/Edit") + edit_item.connect("activate", lambda b: self.launch_edit()) + info_item = uimanager.get_widget("/Popup/Info") + info_item.connect("activate", lambda b: self.launch_info()) + if self.help is not None: + help_item = uimanager.get_widget("/Popup/Help") + help_item.connect("activate", lambda b: self.launch_help()) + if self.url is not None: + url_item = uimanager.get_widget("/Popup/Web_Help") + url_item.connect("activate", lambda b: self.launch_url()) + tab_menu = uimanager.get_widget("/Popup") + tab_menu.popup_at_pointer(event) + return False + + def trigger_tab_detach(self, widget=None): + """Connect this at a higher level to manage tab detachment.""" + pass + + def reshuffle_for_detached(self, add_button, revert_button, parent): + """Reshuffle widgets for detached view.""" + focus_child = self.get_focus_child() + button_hbox = Gtk.Box(homogeneous=False, spacing=0) + self.tool_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + homogeneous=False, + spacing=0, + ) + sep = Gtk.VSeparator() + sep.show() + sep_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + sep_vbox.pack_start(sep, expand=True, fill=True, padding=0) + sep_vbox.set_border_width(metomi.rose.config_editor.SPACING_SUB_PAGE) + sep_vbox.show() + info_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_INFO, + as_tool=True, + tip_text=metomi.rose.config_editor.TAB_MENU_INFO, + ) + info_button.connect("clicked", lambda m: self.launch_info()) + help_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_HELP, + as_tool=True, + tip_text=metomi.rose.config_editor.TAB_MENU_HELP, + ) + help_button.connect("clicked", self.launch_help) + url_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_HOME, + as_tool=True, + tip_text=metomi.rose.config_editor.TAB_MENU_WEB_HELP, + ) + url_button.connect("clicked", self.launch_url) + button_hbox.pack_start(add_button, expand=False, fill=False, padding=0) + button_hbox.pack_start( + revert_button, expand=False, fill=False, padding=0 + ) + button_hbox.pack_start(sep_vbox, expand=False, fill=False, padding=0) + button_hbox.pack_start( + info_button, expand=False, fill=False, padding=0 + ) + if self.help is not None: + button_hbox.pack_start( + help_button, expand=False, fill=False, padding=0 + ) + if self.url is not None: + button_hbox.pack_start( + url_button, expand=False, fill=False, padding=0 + ) + button_hbox.show() + button_frame = Gtk.Frame() + button_frame.set_shadow_type(Gtk.ShadowType.NONE) + button_frame.add(button_hbox) + button_frame.show() + self.tool_hbox.pack_start( + button_frame, expand=False, fill=False, padding=0 + ) + label_box = Gtk.Box( + homogeneous=False, spacing=metomi.rose.config_editor.SPACING_PAGE + ) + label_box.pack_start( + self.get_label_widget(is_detached=True), False, False, 0 + ) + label_box.show() + self.tool_hbox.pack_start( + label_box, expand=True, fill=True, padding=10 + ) + self.tool_hbox.show() + self.pack_start(self.tool_hbox, expand=False, fill=False, padding=0) + self.reorder_child(self.tool_hbox, 0) + if isinstance(parent, Gtk.Window): + if parent.get_child() is not None: + parent.remove(parent.get_child()) + else: + self.close_self() + if focus_child is not None: + focus_child.grab_focus() + + def close_self(self): + """Delete this instance from a metomi.rose.gtk.util.Notebook.""" + parent = self.get_parent() + my_index = parent.get_page_ids().index(self.namespace) + parent.remove_page(my_index) + parent.emit("select-page", False) + + def launch_help(self, *args): + """Launch the page help.""" + title = metomi.rose.config_editor.DIALOG_HELP_TITLE.format(self.label) + metomi.rose.gtk.dialog.run_hyperlink_dialog( + Gtk.STOCK_DIALOG_INFO, str(self.help), title + ) + + def launch_url(self, *args): + """Launch the page url help.""" + webbrowser.open(str(self.url)) + + def update_info(self): + """Driver routine to update non-variable information.""" + button_list, label_list, info = self._get_page_info_widgets() + if [ + label.get_text() for label in label_list + ] == self._last_info_labels: + # No change - do not redraw. + return False + self.generate_page_info(button_list, label_list, info) + has_content = ( + self.info_panel.get_children() + and self.info_panel.get_children()[0].get_children() + ) + if self.info_panel in self.main_vpaned.get_children(): + if not has_content: + self.main_vpaned.remove(self.info_panel) + elif has_content: + self.main_vpaned.pack1(self.info_panel) + + def generate_page_info(self, button_list=None, label_list=None, info=None): + """Generate a widget giving information about sections.""" + info_container = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, homogeneous=False + ) + info_container.show() + if button_list is None or label_list is None or info is None: + button_list, label_list, info = self._get_page_info_widgets() + self._last_info_labels = [label.get_text() for label in label_list] + for button, label in zip(button_list, label_list): + var_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, homogeneous=False + ) + var_hbox.pack_start(button, expand=False, fill=False, padding=0) + var_hbox.pack_start( + label, + expand=False, + fill=True, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + var_hbox.show() + info_container.pack_start( + var_hbox, expand=False, fill=True, padding=0 + ) + # Add page help. + if self.description: + help_label = metomi.rose.gtk.util.get_hyperlink_label( + self.description, search_func=self.search_for_id + ) + help_label_window = Gtk.ScrolledWindow() + help_label_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + help_label_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + help_label_hbox.pack_start( + help_label, expand=False, fill=False, padding=0 + ) + help_label_hbox.show() + help_label_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + help_label_vbox.pack_start( + help_label_hbox, expand=False, fill=False, padding=0 + ) + help_label_vbox.show() + help_label_window.add_with_viewport(help_label_vbox) + help_label_window.get_child().set_shadow_type(Gtk.ShadowType.NONE) + help_label_window.show() + width = help_label_window.get_preferred_size().natural_size.width + height = help_label_window.get_preferred_size().natural_size.height + if info == "Blank page - no data": + self.main_vpaned.set_position( + metomi.rose.config_editor.SIZE_WINDOW[1] * 100 + ) + else: + height = min( + [ + metomi.rose.config_editor.SIZE_WINDOW[1] / 3, + help_label.get_preferred_size().natural_size.height, + ] + ) + help_label_window.set_size_request(width, height) + help_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + help_hbox.pack_start( + help_label_window, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + help_hbox.show() + info_container.pack_start( + help_hbox, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + for child in self.info_panel.get_children(): + self.info_panel.remove(child) + self.info_panel.pack_start( + info_container, expand=True, fill=True, padding=0 + ) + + def generate_filesystem_panel(self): + """Generate a widget to view the file hierarchy.""" + self.filesystem_panel = ( + metomi.rose.config_editor.panelwidget.filesystem.FileSystemPanel( + self.directory + ) + ) + + def generate_sub_data_panel(self, override_custom=False): + """Generate a panel giving a summary of other page data.""" + args = ( + self.sub_data["sections"], + self.sub_data["variables"], + self.section_ops, + self.variable_ops, + self.search_for_id, + self.sub_ops, + self.is_duplicate, + ) + if self.custom_sub_widget is not None and not override_custom: + widget_name_args = self.custom_sub_widget.split(None, 1) + if len(widget_name_args) > 1: + widget_path, widget_args = widget_name_args + else: + widget_path, widget_args = widget_name_args[0], None + metadata_files = self.section_ops.get_ns_metadata_files( + self.namespace + ) + widget_dir = metomi.rose.META_DIR_WIDGET + metadata_files.sort( + key=cmp_to_key( + lambda x, y: (widget_dir in y) - (widget_dir in x) + ) + ) + prefix = re.sub(r"[^\w]", "_", self.config_name.strip("/")) + prefix += "/" + metomi.rose.META_DIR_WIDGET + "/" + custom_widget = metomi.rose.resource.import_object( + widget_path, + metadata_files, + self.handle_bad_custom_sub_widget, + module_prefix=prefix, + ) + if custom_widget is None: + text = metomi.rose.config_editor.ERROR_IMPORT_CLASS.format( + self.custom_sub_widget + ) + self.handle_bad_custom_sub_widget(text) + return False + try: + self.sub_data_panel = custom_widget(*args, arg_str=widget_args) + except Exception as exc: + self.handle_bad_custom_sub_widget(str(exc)) + else: + panel_module = metomi.rose.config_editor.panelwidget.summary_data + self.sub_data_panel = panel_module.StandardSummaryDataPanel(*args) + + def handle_bad_custom_sub_widget(self, error_info): + text = metomi.rose.config_editor.ERROR_IMPORT_WIDGET.format(error_info) + self.reporter(metomi.rose.config_editor.util.ImportWidgetError(text)) + self.generate_sub_data_panel(override_custom=True) + + def update_sub_data(self): + """Update the sub (summary) data panel.""" + if self.sub_data is None: + if ( + hasattr(self, "sub_data_panel") + and self.sub_data_panel is not None + ): + self.vpaned.remove(self.sub_data_panel) + self.sub_data_panel.destroy() + self.sub_data_panel = None + else: + if ( + hasattr(self, "sub_data_panel") + and self.sub_data_panel is not None + ): + self.sub_data_panel.update( + self.sub_data["sections"], self.sub_data["variables"] + ) + + def launch_add_menu(self, event): + """Pop up a contextual add variable menu.""" + add_menu = self.get_add_menu() + if add_menu is None: + return False + add_menu.popup_at_pointer(event) + return False + + def get_add_menu(self): + def _add_var_from_item(item): + for variable in self.ghost_data: + if variable.metadata["id"] == item.var_id: + self.add_row(variable) + return + + add_ui_start = """ + """ + add_ui_end = """ """ + actions = [ + ( + "Add meta", + Gtk.STOCK_DIRECTORY, + metomi.rose.config_editor.ADD_MENU_META, + ) + ] + section_choices = [] + for sect_data in self.sections: + if not sect_data.ignored_reason: + section_choices.append(sect_data.name) + section_choices.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + if self.ns_is_default and section_choices: + add_ui_start = add_ui_start.replace( + "'Popup'>", """'Popup'>""" + ) + text = metomi.rose.config_editor.ADD_MENU_BLANK + if len(section_choices) > 1: + text = metomi.rose.config_editor.ADD_MENU_BLANK_MULTIPLE + actions.insert(0, ("Add blank", Gtk.STOCK_NEW, text)) + ghost_list = [v for v in self.ghost_data] + sorter = metomi.rose.config.sort_settings + ghost_list.sort( + key=cmp_to_key( + lambda v, w: sorter(v.metadata["id"], w.metadata["id"]) + ) + ) + for variable in ghost_list: + label_text = variable.name + if ( + not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_NO_TITLE + ] + and metomi.rose.META_PROP_TITLE in variable.metadata + ): + label_text = variable.metadata[metomi.rose.META_PROP_TITLE] + label_text = label_text.replace("_", "__") + add_ui_start += ( + '' + ) + actions.append((variable.metadata["id"], None, "_" + label_text)) + add_ui = add_ui_start + add_ui_end + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string(add_ui) + if "Add blank" in add_ui: + blank_item = uimanager.get_widget("/Popup/Add blank") + if len(section_choices) > 1: + blank_item.connect( + "activate", + lambda b: self._launch_section_chooser(section_choices), + ) + else: + blank_item.connect("activate", lambda b: self.add_row()) + for variable in ghost_list: + named_item = uimanager.get_widget( + "/Popup/Add meta/" + variable.metadata["id"] + ) + if not named_item: + return None + named_item.var_id = variable.metadata["id"] + tooltip_text = "" + description = variable.metadata.get( + metomi.rose.META_PROP_DESCRIPTION + ) + if description: + tooltip_text += description + "\n" + tooltip_text += "(" + variable.metadata["id"] + ")" + named_item.set_tooltip_text(tooltip_text) + named_item.connect("activate", _add_var_from_item) + if "Add blank" in add_ui or self.ghost_data: + return uimanager.get_widget("/Popup") + return None + + def _launch_section_chooser(self, section_choices): + """Choose a section to add a blank variable to.""" + section = metomi.rose.gtk.dialog.run_choices_dialog( + metomi.rose.config_editor.DIALOG_LABEL_CHOOSE_SECTION_ADD_VAR, + section_choices, + metomi.rose.config_editor.DIALOG_TITLE_CHOOSE_SECTION, + ) + if section is not None: + self.add_row(section=section) + + def add_row(self, variable=None, section=None): + """Append a new variable to the page's main variable list. + + If variable is None, a blank name/value/metadata variable is added. + This is only allowed where there are not multiple config sections + represented in the namespace, as otherwise the location of the + variable in the configuration data is badly defined. + + """ + if variable is None: + if self.section is None and section is None: + return False + creation_time = str(time.time()).replace(".", "_") + if section is None: + sect = self.section.name + else: + sect = section + v_id = sect + "=null" + creation_time + variable = metomi.rose.variable.Variable( + "", "", {"id": v_id, "full_ns": self.namespace} + ) + if section is None and self.section.ignored_reason: + # Cannot add to an ignored section. + return False + self.variable_ops.add_var(variable) + if hasattr(self.main_container, "add_variable_widget"): + self.main_container.add_variable_widget(variable) + self.trigger_update_status() + self.update_ignored() + else: + self.refresh() + self.update_ignored(no_refresh=True) + self.set_main_focus(variable.metadata.get("id")) + + def generate_main_container(self, override_custom=False): + """Choose a container to interface with variables in panel_data.""" + if self.custom_widget is not None and not override_custom: + widget_name_args = self.custom_widget.split(None, 1) + if len(widget_name_args) > 1: + widget_path, widget_args = widget_name_args + else: + widget_path, widget_args = widget_name_args[0], None + metadata_files = self.section_ops.get_ns_metadata_files( + self.namespace + ) + custom_widget = metomi.rose.resource.import_object( + widget_path, metadata_files, self.handle_bad_custom_main_widget + ) + if custom_widget is None: + text = metomi.rose.config_editor.ERROR_IMPORT_CLASS.format( + widget_path + ) + self.handle_bad_custom_main_widget(text) + return + try: + self.main_container = custom_widget( + self.panel_data, + self.ghost_data, + self.variable_ops, + self.show_modes, + arg_str=widget_args, + ) + except Exception as exc: + self.handle_bad_custom_main_widget(exc) + else: + return + std_table = metomi.rose.config_editor.pagewidget.table.PageTable + disc_table = metomi.rose.config_editor.pagewidget.table.PageLatentTable + if self.namespace == "/discovery": + self.main_container = disc_table( + self.panel_data, + self.ghost_data, + self.variable_ops, + self.show_modes, + ) + else: + self.main_container = std_table( + self.panel_data, + self.ghost_data, + self.variable_ops, + self.show_modes, + ) + + def handle_bad_custom_main_widget(self, error_info): + """Handle a bad custom page widget import.""" + text = metomi.rose.config_editor.ERROR_IMPORT_WIDGET.format(error_info) + self.reporter.report( + metomi.rose.config_editor.util.ImportWidgetError(text) + ) + self.generate_main_container(override_custom=True) + + def validate_errors(self, variable_id=None): + """Check if there are there errors in variables on this page.""" + if variable_id is None: + bad_list = [] + for variable in self.panel_data + self.ghost_data: + bad_list += list(variable.error.items()) + return bad_list + else: + for variable in self.panel_data + self.ghost_data: + if variable.metadata.get("id") == variable_id: + if variable.error == {}: + return None + return list(variable.error.items()) + return None + + def choose_focus(self, focus_variable=None): + """Select a widget to have the focus on page generation.""" + if self.custom_widget is not None: + return + if self.show_modes[metomi.rose.config_editor.SHOW_MODE_LATENT]: + for widget in self.get_main_variable_widgets(): + if hasattr(widget.get_parent(), "variable"): + if widget.get_parent().variable.name == "": + widget.get_parent().grab_focus() + return + names = [v.name for v in (self.panel_data + self.ghost_data)] + if focus_variable is None or focus_variable.name not in names: + return + if self.panel_data: + for widget in self.get_main_variable_widgets(): + if hasattr(widget.get_parent(), "variable"): + var = widget.get_parent().variable + if var.name == focus_variable.name: + if var.metadata.get( + "id" + ) == focus_variable.metadata.get("id"): + widget.get_parent().grab_focus() + return + + def refresh(self, only_this_var_id=None): + """Reload the page or selectively refresh widgets for one variable.""" + if only_this_var_id is None: + self.generate_page_info() + return self.sort_main(remake_forced=True) + variable = None + for variable in self.panel_data + self.ghost_data: + if variable.metadata["id"] == only_this_var_id: + break + else: + return self.sort_main(remake_forced=True) + var_id = variable.metadata["id"] + widget_for_var = {} + for widget in self.get_main_variable_widgets(): + if hasattr(widget, "variable"): + target_id = widget.variable.metadata["id"] + target_widget = widget + else: + target_id = widget.get_parent().variable.metadata["id"] + target_widget = widget.get_parent() + widget_for_var.update({target_id: target_widget}) + if variable in self.panel_data: + if var_id in widget_for_var: + widget = widget_for_var[var_id] + if widget.is_ghost: + # Then it is an added ghost variable. + return self.handle_add_var_widget(variable) + # Then it has an existing variable widget. + if ( + (metomi.rose.META_PROP_TYPE in widget.errors) + != (metomi.rose.META_PROP_TYPE in variable.error) + and hasattr(widget, "needs_type_error_refresh") + and not widget.needs_type_error_refresh() + ): + return widget.type_error_refresh(variable) + else: + return self.handle_reload_var_widget(variable) + # Then there were no widgets for this variable. Insert it. + return self.handle_add_var_widget(variable) + else: + if var_id in widget_for_var and widget_for_var[var_id].is_ghost: + # It is a latent variable that needs a refresh. + return self.handle_reload_var_widget(variable) + # It is a normal variable that has been removed. + return self.handle_remove_var_widget(variable) + + def handle_add_var_widget(self, variable): + if hasattr(self.main_container, "add_variable_widget"): + self.main_container.add_variable_widget(variable) + self.update_ignored() + else: + self.refresh() + self.set_main_focus(variable.metadata.get("id")) + + def handle_reload_var_widget(self, variable): + if hasattr(self.main_container, "reload_variable_widget"): + self.main_container.reload_variable_widget(variable) + self.update_ignored() + else: + self.refresh() + + def handle_remove_var_widget(self, variable): + if hasattr(self.main_container, "remove_variable_widget"): + self.main_container.remove_variable_widget(variable) + self.update_ignored() + else: + self.refresh() + + def sort_main(self, column_index=0, ascending=True, remake_forced=False): + """Regenerate a sorted table, according to the arguments. + + column_index maps as {0: index, 1: title, 2: key, 3: value}. + ascending specifies whether to use 'normal' cmp or 'reversed' cmp + arguments. + + """ + if self.sort_data(column_index, ascending) or remake_forced: + focus_var = None + focus_widget = self.get_toplevel().get_focus_child() + if focus_widget is not None and hasattr( + focus_widget.get_parent(), "variable" + ): + focus_var = focus_widget.get_parent().variable + self.main_container.destroy() + self.generate_main_container() + self.scrolled_vbox.pack_start( + self.main_container, expand=False, fill=True, padding=0 + ) + self.choose_focus(focus_var) + self.update_ignored(no_refresh=True) + self.trigger_update_status() + + def get_main_variable_widgets(self): + """Return the widgets within the main_container.""" + return self.get_widgets_with_attribute("variable") + + def get_widgets_with_attribute(self, att_name, parent_widget=None): + """Return widgets with a certain named attribute.""" + if parent_widget is None: + widget_list = self.main_container.get_children() + else: + widget_list = parent_widget.get_children() + i = 0 + while i < len(widget_list): + widget = widget_list[i] + if not ( + hasattr(widget.get_parent(), att_name) + or hasattr(widget, att_name) + ): + widget_list.pop(i) + i -= 1 + if hasattr(widget, "get_children"): + widget_list.extend(widget.get_children()) + elif hasattr(widget, "get_child"): + widget_list.append(widget.get_child()) + i += 1 + return widget_list + + def get_main_focus(self): + """Retrieve the focus variable widget id.""" + widget_list = self.get_main_variable_widgets() + focus_child = self.main_container.get_focus_child() + for widget in widget_list: + if focus_child == widget: + if hasattr(widget.get_parent(), "variable"): + return widget.get_parent().variable.metadata["id"] + elif hasattr(widget, "variable"): + return widget.variable.metadata["id"] + return None + + def set_main_focus(self, var_id): + """Set the main focus on the key-matched variable widget.""" + widget_list = self.get_main_variable_widgets() + for widget in widget_list: + if ( + hasattr(widget.get_parent(), "variable") + and widget.get_parent().variable.metadata["id"] == var_id + ): + widget.get_parent().grab_focus(self.main_container) + return True + for widget in widget_list: + if ( + hasattr(widget, "variable") + and widget.variable.metadata["id"] == var_id + ): + widget.grab_focus() + return True + return False + + def set_sub_focus(self, node_id): + if ( + self.sub_data is not None + and hasattr(self, "sub_data_panel") + and hasattr(self.sub_data_panel, "set_focus_node_id") + ): + self.sub_data_panel.set_focus_node_id(node_id) + + def react_to_show_modes(self, mode_key, is_mode_on): + self.show_modes[mode_key] = is_mode_on + if hasattr(self.main_container, "show_mode_change"): + self.update_ignored() + react_func = getattr(self.main_container, "show_mode_change") + react_func(mode_key, is_mode_on) + elif mode_key in [ + metomi.rose.config_editor.SHOW_MODE_IGNORED, + metomi.rose.config_editor.SHOW_MODE_USER_IGNORED, + ]: + self.update_ignored() + else: + self.refresh() + + def refresh_widget_status(self): + """Refresh the status of all variable widgets.""" + for widget in self.get_widgets_with_attribute("update_status"): + if hasattr(widget.get_parent(), "update_status"): + widget.get_parent().update_status() + else: + widget.update_status() + + def update_ignored(self, no_refresh=False): + """Set variable widgets to 'ignored' or 'enabled' status.""" + new_tuples = [] + for variable in self.panel_data + self.ghost_data: + if variable.ignored_reason: + new_tuples.append( + (variable.metadata["id"], variable.ignored_reason.copy()) + ) + target_widgets_done = [] + refresh_list = [] + relevant_errs = metomi.rose.config_editor.WARNING_TYPES_IGNORE + for widget in self.get_main_variable_widgets(): + if hasattr(widget.get_parent(), "variable"): + target = widget.get_parent() + elif hasattr(widget, "variable"): + target = widget + else: + continue + if target in target_widgets_done: + continue + for var_id, help_text in [x for x in new_tuples]: + if target.variable.metadata.get("id") == var_id: + self._set_widget_ignored(target, help_text) + new_tuples.remove((var_id, help_text)) + break + else: + if hasattr(target, "is_ignored") and target.is_ignored: + self._set_widget_ignored(target, "", enabled=True) + if any(e in target.errors for e in relevant_errs) or any( + e in target.variable.error for e in relevant_errs + ): + if [e in target.errors for e in relevant_errs] != [ + e in target.variable.error for e in relevant_errs + ]: + refresh_list.append(target.variable.metadata["id"]) + target.errors = list(target.variable.error.keys()) + target_widgets_done.append(target) + if hasattr(self.main_container, "update_ignored"): + self.main_container.update_ignored() + elif not no_refresh: + self.refresh() + for variable_id in refresh_list: + self.refresh(variable_id) + + def _check_show_ignored_reason(self, ignored_reason): + """Return whether we should show this state.""" + mode = self.show_modes + if list(ignored_reason.keys()) == [ + metomi.rose.variable.IGNORED_BY_USER + ]: + return ( + mode[metomi.rose.config_editor.SHOW_MODE_IGNORED] + or mode[metomi.rose.config_editor.SHOW_MODE_USER_IGNORED] + ) + return mode[metomi.rose.config_editor.SHOW_MODE_IGNORED] + + def _set_widget_ignored(self, widget, help_text, enabled=False): + if self._check_show_ignored_reason(widget.variable.ignored_reason): + if hasattr(widget, "set_ignored"): + widget.set_ignored() + elif hasattr(widget, "set_sensitive"): + widget.set_sensitive(enabled) + else: + if hasattr(widget, "hide") and hasattr(widget, "show"): + if hasattr(widget, "set_ignored"): + widget.set_ignored() + elif hasattr(widget, "set_sensitive"): + widget.set_sensitive(enabled) + + def reload_from_data(self, new_config_data, new_ghost_data): + """Load the new data into the page as gracefully as possible.""" + for variable in [v for v in self.panel_data]: + # Remove redundant existing variables + var_id = variable.metadata.get("id") + new_id_list = [x.metadata["id"] for x in new_config_data] + if var_id not in new_id_list or var_id is None: + self.variable_ops.remove_var(variable) + for variable in [v for v in self.ghost_data]: + # Remove redundant metadata variables. + var_id = variable.metadata.get("id") + new_id_list = [x.metadata["id"] for x in new_ghost_data] + if var_id not in new_id_list: + self.variable_ops.remove_var(variable) # From the ghost list. + for variable in new_config_data: + # Update or add variables + var_id = variable.metadata["id"] + old_id_list = [x.metadata.get("id") for x in self.panel_data] + if var_id in old_id_list: + old_variable = self.panel_data[old_id_list.index(var_id)] + old_variable.metadata = variable.metadata + if old_variable.value != variable.value: + # Reset the value. + self.variable_ops.set_var_value( + old_variable, variable.value + ) + if old_variable.comments != variable.comments: + self.variable_ops.set_var_comments( + old_variable, variable.comments + ) + old_ign_set = set(old_variable.ignored_reason.keys()) + new_ign_set = set(variable.ignored_reason.keys()) + if old_ign_set != new_ign_set: + # Reset the ignored state. + self.variable_ops.set_var_ignored( + old_variable, + variable.ignored_reason.copy(), + override=True, + ) + else: + # The types are the same, but pass on the info. + old_variable.ignored_reason = ( + variable.ignored_reason.copy() + ) + old_variable.error = variable.error.copy() + old_variable.warning = variable.warning.copy() + else: + self.variable_ops.add_var(variable.copy()) + for variable in new_ghost_data: + # Update or remove variables + var_id = variable.metadata["id"] + old_id_list = [x.metadata.get("id") for x in self.ghost_data] + if var_id in old_id_list: + index = old_id_list.index(var_id) + old_variable = self.ghost_data[index] + old_variable.metadata = variable.metadata.copy() + old_variable.ignored_reason = variable.ignored_reason.copy() + if old_variable.value != variable.value: + old_variable.value = variable.value + old_variable.error = variable.error.copy() + old_variable.warning = variable.warning.copy() + else: + self.ghost_data.append(variable.copy()) + self.refresh() + self.trigger_update_status() + return False + + def sort_data(self, column_index=0, ascending=True, ghost=False): + """Sort page data by an attribute specified with column_index. + + The column_index maps to attributes like this - + {0: index, 1:title, 2:key, 3:value}, where index is the metadata + index (or null string if there isn't one) plus the key. Sorting + does not affect the undo stack. + + """ + sorted_data = [] + if ghost: + datavars = self.ghost_data + else: + datavars = self.panel_data + for variable in datavars: + title = variable.metadata.get( + metomi.rose.META_PROP_TITLE, variable.name + ) + var_id = variable.metadata.get("id", variable.name) + key = ( + variable.metadata.get(metomi.rose.META_PROP_SORT_KEY, "~"), + var_id, + ) + if variable.name == "": + key = ("~", "") + sorted_data.append( + (key, title, variable.name, variable.value, variable) + ) + ascending_cmp = lambda x, y: metomi.rose.config_editor.util.null_cmp( + x[0], y[0] + ) + descending_cmp = lambda x, y: metomi.rose.config_editor.util.null_cmp( + x[0], y[0] + ) + if ascending: + sorted_data.sort(key=cmp_to_key(ascending_cmp)) + else: + sorted_data.sort(key=cmp_to_key(descending_cmp)) + if [x[4] for x in sorted_data] == datavars: + return False + for i, datum in enumerate(sorted_data): + datavars[i] = datum[4] # variable + return True + + def _macro_menu_launch(self): + # Create the popover menu below the widget for macro actions. + self.popover = Gtk.Popover() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + for macro_name, info in sorted(self.custom_macros.items()): + method, description = info + if method == metomi.rose.macro.TRANSFORM_METHOD: + stock_id = Gtk.STOCK_CONVERT + else: + stock_id = "dialog-question" + macro_menuitem_box = Gtk.Box() + macro_menuitem_icon = Gtk.Image.new_from_icon_name( + stock_id, Gtk.IconSize.MENU + ) + macro_menuitem_label = Gtk.Label(label=macro_name) + macro_menuitem = Gtk.Button() + macro_menuitem_box.pack_start(macro_menuitem_icon, False, False, 0) + macro_menuitem_box.pack_start( + macro_menuitem_label, False, False, 0 + ) + Gtk.Container.add(macro_menuitem, macro_menuitem_box) + macro_menuitem.set_tooltip_text(description) + macro_menuitem.show() + macro_menuitem._macro = macro_name + macro_menuitem.connect( + "clicked", lambda m: self.launch_macro(m._macro) + ) + macro_menuitem.set_relief(Gtk.ReliefStyle.NONE) + macro_menuitem.connect( + "leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE) + ) + vbox.pack_start(macro_menuitem, False, True, 10) + vbox.show_all() + self.popover.add(vbox) + self.popover.set_position(Gtk.PositionType.BOTTOM) + + def launch_macro(self, macro_name_string): + """Launch a macro, if possible.""" + class_name = None + method_name = None + if "." in macro_name_string: + module_name, class_name = macro_name_string.split(".", 1) + if "." in class_name: + class_name, method_name = class_name.split(".", 1) + else: + module_name = macro_name_string + self._launch_macro_func( + config_name=self.config_name, + module_name=module_name, + class_name=class_name, + method_name=method_name, + ) + + def search_for_id(self, id_): + """Launch a search for variable or section id.""" + return self.variable_ops.search_for_var(self.namespace, id_) + + def trigger_update_status(self): + """Connect this at a higher level to allow changed data signals.""" + pass + + def _get_page_info_widgets(self): + button_list = [] + label_list = [] + info = "" + # No content warning, if applicable. + has_no_content = ( + self.section is None + and not self.ghost_data + and self.sub_data is None + and not self.latent_sections + ) + if has_no_content: + info = metomi.rose.config_editor.PAGE_WARNING_NO_CONTENT + tip = metomi.rose.config_editor.PAGE_WARNING_NO_CONTENT_TIP + error_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_INFO, as_tool=True, tip_text=tip + ) + error_label = Gtk.Label() + error_label.set_text(info) + error_label.show() + button_list.append(error_button) + label_list.append(error_label) + if self.section is not None and self.section.ignored_reason: + # This adds an ignored warning. + info = ( + metomi.rose.config_editor.PAGE_WARNING_IGNORED_SECTION.format( + self.section.name + ) + ) + tip = metomi.rose.config_editor.PAGE_WARNING_IGNORED_SECTION_TIP + error_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_NO, as_tool=True, tip_text=tip + ) + error_label = Gtk.Label() + error_label.set_text(info) + error_label.show() + button_list.append(error_button) + label_list.append(error_label) + elif ( + self.see_also == "" + or metomi.rose.FILE_VAR_SOURCE not in self.see_also + ): + # This adds an 'orphaned' warning, only if the section is enabled. + if self.section is not None and self.section.name.startswith( + "namelist:" + ): + error_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_DIALOG_WARNING, + as_tool=True, + tip_text=metomi.rose.config_editor.ERROR_ORPHAN_SECTION_TIP, # noqa: E501 + ) + error_label = Gtk.Label() + info = metomi.rose.config_editor.ERROR_ORPHAN_SECTION.format( + self.section.name + ) + error_label.set_text(info) + error_label.show() + button_list.append(error_button) + label_list.append(error_label) + has_data = ( + has_no_content + or self.sub_data is not None + or bool(self.panel_data) + ) + if not has_data: + for section in self.sections: + if section.metadata["full_ns"] == self.namespace: + has_data = True + break + if not has_data: + # This is a latent namespace page. + latent_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_INFO, + as_tool=True, + tip_text=metomi.rose.config_editor.TIP_LATENT_PAGE, + ) + latent_label = Gtk.Label() + latent_label.set_text( + metomi.rose.config_editor.PAGE_WARNING_LATENT + ) + latent_label.show() + button_list.append(latent_button) + label_list.append(latent_label) + # This adds error notification for sections. + for sect_data in self.sections + self.latent_sections: + for err, info in list(sect_data.error.items()): + error_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_DIALOG_ERROR, + as_tool=True, + tip_text=info, + ) + error_label = Gtk.Label() + error_label.set_text( + metomi.rose.config_editor.PAGE_WARNING.format( + err, sect_data.name + ) + ) + error_label.show() + button_list.append(error_button) + label_list.append(error_label) + if list(self.custom_macros.items()): + self._macro_menu_launch() + macro_button_icon = Gtk.Image.new_from_icon_name( + "system-run", Gtk.IconSize.MENU + ) + macro_label = Gtk.Label( + label=metomi.rose.config_editor.LABEL_PAGE_MACRO_BUTTON + ) + macro_button = Gtk.MenuButton( + image=macro_button_icon, + label=metomi.rose.config_editor.LABEL_PAGE_MACRO_BUTTON, + popover=self.popover, + ) + macro_button.set_relief(Gtk.ReliefStyle.NONE) + macro_button.connect( + "leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE) + ) + macro_button.show() + Gtk.Widget.set_name(macro_button, "macro-button") + + button_list.append(macro_button) + label_list.append(macro_label) + return button_list, label_list, info diff --git a/metomi/rose/config_editor/pagewidget/__init__.py b/metomi/rose/config_editor/pagewidget/__init__.py new file mode 100644 index 0000000000..e733a1258d --- /dev/null +++ b/metomi/rose/config_editor/pagewidget/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +# flake8: noqa: F401 +from . import table diff --git a/metomi/rose/config_editor/pagewidget/table.py b/metomi/rose/config_editor/pagewidget/table.py new file mode 100644 index 0000000000..22c1c530d4 --- /dev/null +++ b/metomi/rose/config_editor/pagewidget/table.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import shlex + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config +import metomi.rose.config_editor.util +import metomi.rose.config_editor.variable +import metomi.rose.formats +import metomi.rose.variable + +from functools import cmp_to_key + + +class PageTable(Gtk.Table): + """Return a widget table generated from panel_data. + + It uses the variable information to create instances of + VariableWidget, which are then asked to insert themselves into the + table. + + """ + + MAX_ROWS = 2000 + MAX_COLS = 3 + BORDER_WIDTH = metomi.rose.config_editor.SPACING_SUB_PAGE + + def __init__( + self, panel_data, ghost_data, var_ops, show_modes, arg_str=None + ): + super(PageTable, self).__init__( + rows=self.MAX_ROWS, columns=self.MAX_COLS, homogeneous=False + ) + self.panel_data = panel_data + self.ghost_data = ghost_data + self.var_ops = var_ops + self.show_modes = show_modes + variable_is_ghost_list = self._get_sorted_variables() + self.attach_variable_widgets(variable_is_ghost_list, start_index=0) + self._show_and_hide_variable_widgets() + self.show() + + def add_variable_widget(self, variable): + """Add a variable widget that was previously in ghost_data.""" + new_variable_widget = self.get_variable_widget(variable) + widget_coordinate_list = [] + for child in self.get_children(): + top_row = self.child_get(child, "top_attach")[0] + variable_widget = child.get_parent() + if variable_widget not in [x[0] for x in widget_coordinate_list]: + widget_coordinate_list.append((variable_widget, top_row)) + widget_coordinate_list.sort(key=lambda x: x[1]) + old_index = None + for widget, index in widget_coordinate_list: + if widget.variable.metadata["id"] == variable.metadata["id"]: + old_index = index + break + if old_index is None: + variable_is_ghost_list = self._get_sorted_variables() + variables = [x[0] for x in variable_is_ghost_list] + num_vars_above_this = variables.index(variable) + if num_vars_above_this == 0: + row_above_new = -1 + else: + row_above_new = widget_coordinate_list[ + num_vars_above_this - 1 + ][1] + for variable_widget, widget_row in widget_coordinate_list: + if widget_row > row_above_new: + for child in self.get_children(): + if child.get_parent() == variable_widget: + self.remove(child) + new_variable_widget.insert_into( + self, self.MAX_COLS, row_above_new + 1 + ) + self._show_and_hide_variable_widgets(new_variable_widget) + rownum = row_above_new + 2 + for variable_widget, widget_row in widget_coordinate_list: + if ( + widget_row > row_above_new + and variable_widget.variable.metadata.get("id") + != variable.metadata.get("id") + ): + variable_widget.insert_into(self, self.MAX_COLS, rownum) + rownum += 1 + else: + self.reload_variable_widget(variable) + + def attach_variable_widgets(self, variable_is_ghost_list, start_index=0): + """Create and attach variable widgets for these inputs.""" + rownum = start_index + for variable, is_ghost in variable_is_ghost_list: + variable_widget = self.get_variable_widget(variable, is_ghost) + variable_widget.insert_into(self, self.MAX_COLS, rownum + 1) + variable_widget.set_sensitive(not is_ghost) + rownum += 1 + + def get_variable_widget(self, variable, is_ghost=False): + """Create a variable widget for this variable.""" + return metomi.rose.config_editor.variable.VariableWidget( + variable, + self.var_ops, + is_ghost=is_ghost, + show_modes=self.show_modes, + ) + + def reload_variable_widget(self, variable): + """Reload the widgets for the given variable.""" + is_ghost = variable in self.ghost_data + new_variable_widget = self.get_variable_widget(variable, is_ghost) + new_variable_widget.set_sensitive(not is_ghost) + focus_dict = {"had_focus": False} + variable_row = None + for child in self.get_children(): + variable_widget = child.get_parent() + if ( + variable_widget.variable.name == variable.name + and variable_widget.variable.metadata.get("id") + == variable.metadata.get("id") + ): + if "index" not in focus_dict: + focus_dict["index"] = variable_widget.get_focus_index() + if self.get_focus_child() == child: + focus_dict["had_focus"] = True + top_row = self.child_get(child, "top_attach")[0] + variable_row = top_row + self.remove(child) + child.destroy() + if variable_row is None: + return False + new_variable_widget.insert_into(self, self.MAX_COLS, variable_row) + self._show_and_hide_variable_widgets(new_variable_widget) + if focus_dict["had_focus"]: + new_variable_widget.grab_focus(index=focus_dict.get("index")) + + def remove_variable_widget(self, variable): + """Remove the selected widget and/or relocate to ghosts.""" + self.reload_variable_widget(variable) + + def _get_sorted_variables(self): + sort_key_vars = [] + for val in self.panel_data + self.ghost_data: + sort_key = ( + (val.metadata.get("sort-key", "~")), + val.metadata["id"], + ) + is_ghost = val in self.ghost_data + sort_key_vars.append((sort_key, val, is_ghost)) + sort_key_vars.sort( + key=cmp_to_key( + lambda x, y: metomi.rose.config_editor.util.null_cmp( + x[0], y[0] + ) + ) + ) + sort_key_vars.sort(key=lambda x: "=null" in x[1].metadata["id"]) + return [(x[1], x[2]) for x in sort_key_vars] + + def _show_and_hide_variable_widgets(self, just_this_widget=None): + """Figure out whether to display a widget or not.""" + modes = self.show_modes + if just_this_widget: + variable_widgets = [just_this_widget] + else: + variable_widgets = [] + for child in self.get_children(): + if child.get_parent() not in variable_widgets: + variable_widgets.append(child.get_parent()) + for variable_widget in variable_widgets: + variable = variable_widget.variable + ign_reason = variable.ignored_reason + if variable.error: + variable_widget.show() + elif ( + len(variable.metadata.get(metomi.rose.META_PROP_VALUES, [])) + == 1 + and not modes[metomi.rose.config_editor.SHOW_MODE_FIXED] + ): + variable_widget.hide() + elif ( + variable_widget.is_ghost + and not modes[metomi.rose.config_editor.SHOW_MODE_LATENT] + ): + variable_widget.hide() + elif ( + metomi.rose.variable.IGNORED_BY_SYSTEM in ign_reason + or metomi.rose.variable.IGNORED_BY_SECTION in ign_reason + ) and not modes[metomi.rose.config_editor.SHOW_MODE_IGNORED]: + variable_widget.hide() + elif metomi.rose.variable.IGNORED_BY_USER in ign_reason and not ( + modes[metomi.rose.config_editor.SHOW_MODE_IGNORED] + or modes[metomi.rose.config_editor.SHOW_MODE_USER_IGNORED] + ): + variable_widget.hide() + else: + variable_widget.show() + + def show_mode_change(self, mode, mode_on=False): + done_variable_widgets = [] + for child in self.get_children(): + parent = child.get_parent() + if parent in done_variable_widgets: + continue + parent.set_show_mode(mode, mode_on) + done_variable_widgets.append(parent) + self._show_and_hide_variable_widgets() + + def update_ignored(self): + self._show_and_hide_variable_widgets() + + +class PageArrayTable(PageTable): + """Return a widget table that treats array values as row elements.""" + + def __init__(self, *args, **kwargs): + arg_str = kwargs.get("arg_str", "") + if arg_str is None: + arg_str = "" + self.headings = shlex.split(arg_str) + super(PageArrayTable, self).__init__(*args, **kwargs) + self._set_length() + + def attach_variable_widgets(self, variable_is_ghost_list, start_index=0): + """Create and attach variable widgets for these inputs.""" + self._set_length() + rownum = start_index + for variable, is_ghost in variable_is_ghost_list: + variable_widget = self.get_variable_widget(variable, is_ghost) + variable_widget.insert_into(self, self.MAX_COLS, rownum + 1) + variable_widget.set_sensitive(not is_ghost) + rownum += 1 + + def get_variable_widget(self, variable, is_ghost=False): + """Create a variable widget for this variable.""" + if metomi.rose.META_PROP_LENGTH in variable.metadata or isinstance( + variable.metadata.get(metomi.rose.META_PROP_TYPE), list + ): + return metomi.rose.config_editor.variable.RowVariableWidget( + variable, + self.var_ops, + is_ghost=is_ghost, + show_modes=self.show_modes, + length=self.array_length, + ) + return metomi.rose.config_editor.variable.VariableWidget( + variable, + self.var_ops, + is_ghost=is_ghost, + show_modes=self.show_modes, + ) + + def _set_length(self): + max_meta_length = 0 + max_values_length = 0 + for variable in self.panel_data + self.ghost_data: + length = variable.metadata.get(metomi.rose.META_PROP_LENGTH) + if ( + length is not None + and length.isdigit() + and int(length) > max_meta_length + ): + max_meta_length = int(length) + types = variable.metadata.get(metomi.rose.META_PROP_TYPE) + if isinstance(types, list) and len(types) > max_meta_length: + max_meta_length = len(types) + values_length = len( + metomi.rose.variable.array_split(variable.value) + ) + if values_length > max_values_length: + max_values_length = values_length + self.array_length = max([max_meta_length, max_values_length]) + + +class PageLatentTable(Gtk.Table): + """Return a widget table generated from panel_data. + + It uses the variable information to create instances of + VariableWidget, which are then asked to insert themselves into the + table. + + This particular container always shows latent variables. + + """ + + MAX_ROWS = 2000 + MAX_COLS = 3 + + def __init__( + self, panel_data, ghost_data, var_ops, show_modes, arg_str=None + ): + super(PageLatentTable, self).__init__( + rows=self.MAX_ROWS, columns=self.MAX_COLS, homogeneous=False + ) + self.show() + self.num_removes = 0 + self.panel_data = panel_data + self.ghost_data = ghost_data + self.var_ops = var_ops + self.show_modes = show_modes + self.title_on = not self.show_modes[ + metomi.rose.config_editor.SHOW_MODE_NO_TITLE + ] + self.alt_menu_class = ( + metomi.rose.config_editor.menuwidget.CheckedMenuWidget + ) + rownum = 0 + v_sort_ids = [] + for val in self.panel_data + self.ghost_data: + v_sort_ids.append( + (val.metadata.get("sort-key", ""), val.metadata["id"]) + ) + v_sort_ids.sort( + key=cmp_to_key( + lambda x, y: metomi.rose.config.sort_settings( + x[0] + "~" + x[1], y[0] + "~" + y[1] + ) + ) + ) + v_sort_ids.sort(key=lambda x: "=null" in x[1]) + for _, var_id in v_sort_ids: + is_ghost = False + for variable in self.panel_data: + if variable.metadata["id"] == var_id: + break + else: + for variable in self.ghost_data: + if variable.metadata["id"] == var_id: + is_ghost = True + break + variable_widget = self.get_variable_widget( + variable, is_ghost=is_ghost + ) + variable_widget.insert_into(self, self.MAX_COLS, rownum + 1) + variable_widget.set_sensitive(not is_ghost) + rownum += 1 + + def get_variable_widget(self, variable, is_ghost=False): + """Create a variable widget for this variable.""" + return metomi.rose.config_editor.variable.VariableWidget( + variable, + self.var_ops, + is_ghost=is_ghost, + show_modes=self.show_modes, + ) + + def reload_variable_widget(self, variable): + """Reload the widgets for the given variable.""" + is_ghost = variable in self.ghost_data + new_variable_widget = self.get_variable_widget(variable, is_ghost) + new_variable_widget.set_sensitive(not is_ghost) + focus_dict = {"had_focus": False} + variable_row = None + for child in self.get_children(): + variable_widget = child.get_parent() + if ( + variable_widget.variable.name == variable.name + and variable_widget.variable.metadata.get("id") + == variable.metadata.get("id") + ): + if "index" not in focus_dict: + focus_dict["index"] = variable_widget.get_focus_index() + if getattr(self, "focus_child") == child: + focus_dict["had_focus"] = True + top_row = self.child_get(child, "top_attach")[0] + variable_row = top_row + self.remove(child) + child.destroy() + if variable_row is None: + return False + new_variable_widget.insert_into(self, self.MAX_COLS, variable_row) + if focus_dict["had_focus"]: + new_variable_widget.grab_focus(index=focus_dict.get("index")) + + def show_mode_change(self, mode, mode_on=False): + done_variable_widgets = [] + for child in self.get_children(): + parent = child.get_parent() + if parent in done_variable_widgets: + continue + parent.set_show_mode(mode, mode_on) + done_variable_widgets.append(parent) + + def refresh(self, var_id=None): + """Reload the container - don't need this at the moment.""" + pass + + def update_ignored(self): + """Update ignored statuses - no need to do anything extra here.""" + pass diff --git a/metomi/rose/config_editor/panelwidget/__init__.py b/metomi/rose/config_editor/panelwidget/__init__.py new file mode 100644 index 0000000000..07014eb5ad --- /dev/null +++ b/metomi/rose/config_editor/panelwidget/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +# flake8: noqa: F401 +from . import filesystem +from . import summary_data diff --git a/metomi/rose/config_editor/panelwidget/filesystem.py b/metomi/rose/config_editor/panelwidget/filesystem.py new file mode 100644 index 0000000000..f3b417dea5 --- /dev/null +++ b/metomi/rose/config_editor/panelwidget/filesystem.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import os + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config_editor +import metomi.rose.external +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util + + +class FileSystemPanel(Gtk.ScrolledWindow): + """A class to show underlying files and directories in a Gtk.TreeView.""" + + def __init__(self, directory): + super(FileSystemPanel, self).__init__() + self.directory = directory + view = Gtk.TreeView() + store = Gtk.TreeStore(str, str) + dirpath_iters = {self.directory: None} + for dirpath, dirnames, filenames in os.walk(self.directory): + if dirpath not in dirpath_iters: + known_path = os.path.dirname(dirpath) + new_iter = store.append( + dirpath_iters[known_path], + [os.path.basename(dirpath), os.path.abspath(dirpath)], + ) + dirpath_iters.update({dirpath: new_iter}) + this_iter = dirpath_iters[dirpath] + filenames.sort() + for name in filenames: + if name in metomi.rose.CONFIG_NAMES: + continue + filepath = os.path.join(dirpath, name) + store.append(this_iter, [name, os.path.abspath(filepath)]) + for dirname in list(dirnames): + if dirname.startswith(".") or dirname in [ + metomi.rose.SUB_CONFIGS_DIR, + metomi.rose.CONFIG_META_DIR, + ]: + dirnames.remove(dirname) + dirnames.sort() + view.set_model(store) + col = Gtk.TreeViewColumn() + col.set_title(metomi.rose.config_editor.TITLE_FILE_PANEL) + cell = Gtk.CellRendererText() + col.pack_start(cell, True) + col.set_cell_data_func(cell, self._set_path_markup, store) + view.append_column(col) + view.expand_all() + view.show() + view.connect("row-activated", self._handle_activation) + view.connect("button-press-event", self._handle_click) + self.add(view) + self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self.show() + + def _set_path_markup(self, column, cell, model, r_iter, treestore): + title = model.get_value(r_iter, 0) + title = metomi.rose.gtk.util.safe_str(title) + cell.set_property("markup", title) + + def _handle_activation(self, view=None, path=None, col=None): + target_func = metomi.rose.external.launch_fs_browser + if path is None: + target = self.directory + else: + model = view.get_model() + row_iter = model.get_iter(path) + fs_path = model.get_value(row_iter, 1) + target = fs_path + if not os.path.isdir(target): + target_func = metomi.rose.external.launch_geditor + try: + target_func(target) + except Exception as exc: + metomi.rose.gtk.dialog.run_exception_dialog(exc) + + def _handle_click(self, view, event): + pathinfo = view.get_path_at_pos(int(event.x), int(event.y)) + if ( + event.button == 1 + and event.type == Gdk.EventType._2BUTTON_PRESS + and pathinfo is None + ): + self._handle_activation() + if event.button == 3: + ui_string = """ + + """ + actions = [ + ( + "Open", + Gtk.STOCK_OPEN, + metomi.rose.config_editor.FILE_PANEL_MENU_OPEN, + ) + ] + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string(ui_string) + if pathinfo is None: + path = None + col = None + else: + path, col = pathinfo[:2] + open_item = uimanager.get_widget("/Popup/Open") + open_item.connect( + "activate", lambda m: self._handle_activation(view, path, col) + ) + this_menu = uimanager.get_widget("/Popup") + this_menu.popup_at_widget( + event.button, + Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, + event, + ) diff --git a/metomi/rose/config_editor/panelwidget/summary_data.py b/metomi/rose/config_editor/panelwidget/summary_data.py new file mode 100644 index 0000000000..2b6c252026 --- /dev/null +++ b/metomi/rose/config_editor/panelwidget/summary_data.py @@ -0,0 +1,1055 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk +from gi.repository import Pango + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.config_editor.util +import metomi.rose.gtk.util + +from functools import cmp_to_key + + +class BaseSummaryDataPanel(Gtk.Box): + """A base class for summarising data across many namespaces. + + Subclasses should provide the following methods: + - def add_cell_renderer_for_value(self, column, column_title): + - def get_model_data(self): + - def get_section_column_index(self): + - def set_tree_cell_status(self, column, cell, model, row_iter): + - def set_tree_tip(self, treeview, row_iter, col_index, tip): + + Subclasses may provide the following methods: + - def _get_custom_menu_items(self, path, column, event): + + These are described below in their placeholder methods. + + """ + + def __init__( + self, + sections, + variables, + sect_ops, + var_ops, + search_function, + sub_ops, + is_duplicate, + arg_str=None, + ): + super(BaseSummaryDataPanel, self).__init__( + orientation=Gtk.Orientation.VERTICAL + ) + self.sections = sections + self.variables = variables + self._section_data_list = None + self._last_column_names = [] + self.column_names = [] + self.sect_ops = sect_ops + self.var_ops = var_ops + self.search_function = search_function + self.sub_ops = sub_ops + self.is_duplicate = is_duplicate + self.group_index = None + self.util = metomi.rose.config_editor.util.Lookup() + self.control_widget_hbox = self._get_control_widget_hbox() + self.pack_start( + self.control_widget_hbox, expand=False, fill=False, padding=0 + ) + self._prev_store = None + self._prev_sort_model = None + self._view = metomi.rose.gtk.util.TooltipTreeView( + get_tooltip_func=self.set_tree_tip, multiple_selection=True + ) + self._view.set_rules_hint(True) + self.sort_util = metomi.rose.gtk.util.TreeModelSortUtil( + self._view.get_model, multi_sort_num=2 + ) + self._view.show() + self._view.connect( + "button-press-event", self._handle_button_press_event + ) + self._view.connect("key-press-event", self._handle_key_press_event) + self._window = Gtk.ScrolledWindow() + self._window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + self.update() + self._window.add(self._view) + self._window.show() + self.pack_start(self._window, expand=True, fill=True, padding=0) + self.show() + + def add_cell_renderer_for_value(self, column, column_title): + """Add a cell renderer to represent the model value. + + column is the Gtk.TreeColumn to pack the cell in. + column_title is the title of column. + + You may want to use column.set_cell_data_func. + + """ + raise NotImplementedError() + + def get_model_data(self): + """Return a list of data tuples, plus column names. + + The returned list should contain lists of items for each row. + The column names should be a list of strings for column titles. + + """ + raise NotImplementedError() + + def get_section_column_index(self): + """Return the section name column index from the Gtk.TreeView. + + This may change based on the grouping (self.group_index). + + """ + raise NotImplementedError() + + def set_tree_cell_status(self, column, cell, model, row_iter, _): + """Add status markup to the cell - e.g. error notification. + + column is the Gtk.TreeColumn where the cell is + cell is the Gtk.CellRendererText to add markup to + model is the Gtk.TreeModel-derived data store + row_iter is the Gtk.TreeIter pointing to the cell's row + + """ + raise NotImplementedError() + + def set_tree_tip(self, treeview, row_iter, col_index, tip): + """Add the hover-over text for a cell to 'tip'. + + treeview is the Gtk.TreeView object + row_iter is the Gtk.TreeIter for the row + col_index is the index of the Gtk.TreeColumn in + e.g. treeview.get_columns() + tip is the Gtk.Tooltip object that the text needs to be set in. + + """ + raise NotImplementedError() + + def _get_custom_menu_items(self, path, column, event): + """Override this method to add to the right click menu. + + This should return a list of Gtk.MenuItem subclass instances. + + """ + return [] + + def _get_control_widget_hbox(self): + filter_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_FILTER_LABEL + ) + filter_label.show() + self._filter_widget = Gtk.Entry() + self._filter_widget.set_width_chars( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_FILTER_MAX_CHAR + ) + self._filter_widget.connect("changed", self._refilter) + self._filter_widget.show() + group_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_GROUP_LABEL + ) + group_label.show() + self._group_widget = Gtk.ComboBox() + cell = Gtk.CellRendererText() + self._group_widget.pack_start(cell, True) + self._group_widget.add_attribute(cell, "text", 0) + self._group_widget.connect("changed", self._handle_group_change) + self._group_widget.show() + filter_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + filter_hbox.pack_start( + group_label, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_start( + self._group_widget, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_start( + filter_label, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + filter_hbox.pack_start( + self._filter_widget, expand=False, fill=False, padding=0 + ) + filter_hbox.show() + return filter_hbox + + def update_tree_model(self): + """Construct a data model of other page data.""" + self.var_id_map = {} + for variables in list(self.variables.values()): + for variable in variables: + self.var_id_map[variable.metadata["id"]] = variable + data_rows, column_names = self.get_model_data() + data_rows, column_names, rows_are_descendants = self._apply_grouping( + data_rows, column_names, self.group_index + ) + self.column_names = column_names + should_redraw = self.column_names != self._last_column_names + if data_rows: + col_types = [str] * len(data_rows[0]) + else: + col_types = [] + need_new_store = should_redraw or self.group_index + if need_new_store: + # We need to construct a new TreeModel. + if self._prev_sort_model is not None: + prev_sort_id = self._prev_sort_model.get_sort_column_id() + store = Gtk.TreeStore(*col_types) + self._prev_store = store + else: + store = self._prev_store + parent_iter = None + for i, row_data in enumerate(data_rows): + insert_iter = store.iter_nth_child(None, i) + if insert_iter is not None: + for j, value in enumerate(row_data): + store.set_value(insert_iter, j, value) + elif not rows_are_descendants: + store.append(None, row_data) + elif rows_are_descendants[i]: + store.append(parent_iter, row_data) + else: + parent_data = [row_data[0]] + [None] * len(row_data[1:]) + parent_iter = store.append(None, parent_data) + store.append(parent_iter, row_data) + for extra_index in range(i + 1, store.iter_n_children(None)): + remove_iter = store.iter_nth_child(None, extra_index) + if remove_iter is not None: + store.remove(remove_iter) + if need_new_store: + filter_model = store.filter_new() + filter_model.set_visible_func(self._filter_visible) + sort_model = Gtk.TreeModelSort(filter_model) + for i in range(len(self.column_names)): + sort_model.set_sort_func(i, self.sort_util.sort_column, i) + if ( + self._prev_sort_model is not None + and prev_sort_id[0] is not None + ): + sort_model.set_sort_column_id(*prev_sort_id) + self._prev_sort_model = sort_model + sort_model.connect( + "sort-column-changed", self.sort_util.handle_sort_column_change + ) + if should_redraw: + self.sort_util.clear_sort_columns() + for column in list(self._view.get_columns()): + self._view.remove_column(column) + self._view.set_model(sort_model) + self._last_column_names = self.column_names + return should_redraw + + def set_focus_node_id(self, node_id): + """Set the focus on a particular node id, if possible.""" + section = self.util.get_section_option_from_id(node_id)[0] + self.scroll_to_section(section) + + def update(self, sections=None, variables=None): + """Update the summary of page data.""" + if sections is not None: + self.sections = sections + if variables is not None: + self.variables = variables + old_cols = set(self.column_names) + + # Generate a set of expanded sections (one level only). + expanded_sections = set() + model = self._view.get_model() + self._view.map_expanded_rows( + lambda r, p: expanded_sections.add( + model.get_value(model.get_iter(p), 0) + ) + ) + + should_redraw = self.update_tree_model() + if should_redraw: + self.add_new_columns(self._view, self.column_names) + if old_cols != set(self.column_names): + iter_ = self._group_widget.get_active_iter() + if self.group_index is not None: + current_model = self._group_widget.get_model() + current_group = None + if current_model is not None: + current_group = current_model().get_value(iter_, 0) + group_model = Gtk.TreeStore(str) + group_model.append(None, [""]) + start_index = 0 + for i, name in enumerate(self.column_names): + if self.group_index is not None and name == current_group: + start_index = i + group_model.append(None, [name]) + if self.group_index is not None: + group_model.append(None, [""]) + self._group_widget.set_model(group_model) + self._group_widget.set_active(start_index) + model = self._view.get_model() + + # Expand previously expanded sections (one level only). + path = (0,) + while True: + if model.get_value(model.get_iter(path), 0) in expanded_sections: + self._view.expand_to_path(path) + + sibling = model.iter_next(model.get_iter(path)) + if sibling: + path = model.get_path(sibling) + else: + break + + def add_new_columns(self, treeview, column_names): + """Create new columns.""" + for i, column_name in enumerate(column_names): + col = Gtk.TreeViewColumn() + col.set_title(column_name.replace("_", "__")) + cell_for_status = Gtk.CellRendererText() + col.pack_start(cell_for_status, False) + col.set_cell_data_func(cell_for_status, self.set_tree_cell_status) + self.add_cell_renderer_for_value(col, column_name) + if i < len(column_names) - 1: + col.set_resizable(True) + col.set_sort_column_id(i) + treeview.append_column(col) + + def get_status_from_data(self, node_data): + """Return markup corresponding to changes since the last save.""" + text = "" + mod_markup = ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_MODIFIED_MARKUP + ) + err_markup = metomi.rose.config_editor.SUMMARY_DATA_PANEL_ERROR_MARKUP + if node_data is None: + return None + if metomi.rose.variable.IGNORED_BY_SYSTEM in node_data.ignored_reason: + text += ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_IGNORED_SYST_MARKUP # noqa: E501 + ) + elif metomi.rose.variable.IGNORED_BY_USER in node_data.ignored_reason: + text += ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_IGNORED_USER_MARKUP # noqa: E501 + ) + if metomi.rose.variable.IGNORED_BY_SECTION in node_data.ignored_reason: + text += ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_IGNORED_SECT_MARKUP # noqa: E501 + ) + if isinstance(node_data, metomi.rose.section.Section): + # Modified status + section = node_data.metadata["id"] + if self.sect_ops.is_section_modified(node_data): + text += mod_markup + else: + for var in self.variables.get(section, []): + if self.var_ops.is_var_modified(var): + text += mod_markup + break + # Error status + if node_data.error: + text += err_markup + else: + for var in self.variables.get(section, []): + if var.error: + text += err_markup + break + elif isinstance(node_data, metomi.rose.variable.Variable): + if self.var_ops.is_var_modified(node_data): + text += mod_markup + if node_data.error: + text += err_markup + return text + + def _refilter(self, widget=None): + self._view.get_model().get_model().refilter() + + def _filter_visible(self, model, iter_, _): + filt_text = self._filter_widget.get_text() + if not filt_text: + return True + for i in range(model.get_n_columns()): + col_text = model.get_value(iter_, i) + if isinstance(col_text, str) and filt_text in col_text: + return True + child_iter = model.iter_children(iter_) + while child_iter is not None: + if self._filter_visible(model, child_iter, _): + return True + child_iter = model.iter_next(child_iter) + return False + + def _handle_activation(self, view, path, column): + if path is None: + return False + model = view.get_model() + row_iter = model.get_iter(path) + col_index = view.get_columns().index(column) + cell_data = model.get_value(row_iter, col_index) + sect_index = self.get_section_column_index() + section = model.get_value(row_iter, sect_index) + option = None + if col_index != sect_index and cell_data is not None: + option = self.column_names[col_index] + id_ = self.util.get_id_from_section_option(section, option) + self.search_function(id_) + + def _handle_button_press_event(self, treeview, event): + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is not None: + path, col = pathinfo[0:2] + if event.button == 3: + rows = self._view.get_selection().get_selected_rows()[1] + if len(rows) > 1: + # Multiple selection. + self._popup_tree_multi_menu(event) + else: + # Single selection. + self._popup_tree_menu(path, col, event) + return True + elif event.button == 2: + self._handle_activation(treeview, path, col) + return False + + def _get_selected_sections(self): + """Return a set of the names of any sections that are currently being + selected by the user.""" + ret = set([]) + col = self.get_section_column_index() + model, rows = self._view.get_selection().get_selected_rows() + for row in rows: + iter_ = model.get_iter(row) + section = model.get_value(iter_, col) + if section: + # This row is a section. + ret.add(section) + else: + # This may be the parent of some sections. + ret.update(self._get_child_row_sections(model, iter_, col)) + return ret + + def _get_child_row_sections(self, model, iter_, col): + """Return a set of any sub-sections contained within the section + represented by the provided iter_. Method is recursive so will iterate + down the tree.""" + ret = set([]) + for child_no in range(model.iter_n_children(iter_)): + child_iter = model.iter_nth_child(iter_, child_no) + if not child_iter: + continue + section = model.get_value(child_iter, col) + if not section: + # Recursively acquire sub-sections if present. + ret.update(self._get_child_rows(child_iter)) + ret.add(section) + return ret + + def _popup_tree_multi_menu(self, event): + """Launch a menu for these main treeview rows (multi-selection).""" + menu = Gtk.Menu() + menu.show() + shortcuts = [] + + # Ignore all. + ign_menuitem_box = Gtk.Box() + ign_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_NO, Gtk.IconSize.MENU + ) + ign_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_IGNORE_MULTI # noqa: E501 + ) + ign_menuitem = Gtk.MenuItem() + ign_menuitem_box.pack_start(ign_menuitem_icon, False, False, 0) + ign_menuitem_box.pack_start(ign_menuitem_label, False, False, 0) + Gtk.Container.add(ign_menuitem, ign_menuitem_box) + ign_menuitem.connect("activate", self._ignore_selected_sections, True) + ign_menuitem.show() + menu.append(ign_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, ign_menuitem) + ) + # Enable all. + ign_menuitem_box = Gtk.Box() + ign_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_YES, Gtk.IconSize.MENU + ) + ign_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_ENABLE_MULTI # noqa: E501 + ) + ign_menuitem = Gtk.MenuItem() + ign_menuitem_box.pack_start(ign_menuitem_icon, False, False, 0) + ign_menuitem_box.pack_start(ign_menuitem_label, False, False, 0) + Gtk.Container.add(ign_menuitem, ign_menuitem_box) + ign_menuitem.connect("activate", self._ignore_selected_sections, False) + ign_menuitem.show() + menu.append(ign_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, ign_menuitem) + ) + # Remove all. + rem_menuitem_box = Gtk.Box() + rem_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + rem_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_REMOVE_MULTI # noqa: E501 + ) + rem_menuitem = Gtk.MenuItem() + rem_menuitem_box.pack_start(rem_menuitem_icon, False, False, 0) + rem_menuitem_box.pack_start(rem_menuitem_label, False, False, 0) + Gtk.Container.add(rem_menuitem, rem_menuitem_box) + rem_menuitem.connect("activate", self._remove_selected_sections) + rem_menuitem.show() + menu.append(rem_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_REMOVE, rem_menuitem) + ) + + # list shortcut keys + accel = Gtk.AccelGroup() + menu.set_accel_group(accel) + for key_press, menuitem in shortcuts: + key, mod = Gtk.accelerator_parse(key_press) + menuitem.add_accelerator( + "activate", accel, key, mod, Gtk.AccelFlags.VISIBLE + ) + + menu.popup_at_widget( + event.button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + return False + + def _popup_tree_menu(self, path, col, event): + """Launch a menu for this main treeview row (single selection).""" + shortcuts = [] + menu = Gtk.Menu() + model = self._view.get_model() + row_iter = model.get_iter(path) + sect_index = self.get_section_column_index() + this_section = model.get_value(row_iter, sect_index) + if this_section is not None: + # Jump to section. + label = ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_GO_TO.format( + this_section.replace("_", "__") + ) + ) + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_JUMP_TO, Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label(label=label) + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + menuitem._section = this_section + menuitem.connect( + "activate", lambda i: self.search_function(i._section) + ) + menuitem.show() + menu.append(menuitem) + sep = Gtk.SeparatorMenuItem() + sep.show() + menu.append(sep) + extra_menuitems = self._get_custom_menu_items(path, col, event) + if extra_menuitems: + for extra_menuitem in extra_menuitems: + menu.append(extra_menuitem) + if this_section is not None: + sep = Gtk.SeparatorMenuItem() + sep.show() + menu.append(sep) + if self.is_duplicate: + # A section is currently selected + if this_section is not None: + # Add section. + add_menuitem_box = Gtk.Box() + add_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_ADD, Gtk.IconSize.MENU + ) + add_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_ADD + ) + add_menuitem = Gtk.MenuItem() + add_menuitem_box.pack_start(add_menuitem_icon, False, False, 0) + add_menuitem_box.pack_start( + add_menuitem_label, False, False, 0 + ) + Gtk.Container.add(add_menuitem, add_menuitem_box) + add_menuitem.connect("activate", lambda i: self.add_section()) + add_menuitem.show() + menu.append(add_menuitem) + # Copy section. + copy_menuitem_box = Gtk.Box() + copy_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_COPY, Gtk.IconSize.MENU + ) + copy_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_COPY # noqa: E501 + ) + copy_menuitem = Gtk.MenuItem() + copy_menuitem_box.pack_start( + copy_menuitem_icon, False, False, 0 + ) + copy_menuitem_box.pack_start( + copy_menuitem_label, False, False, 0 + ) + Gtk.Container.add(copy_menuitem, copy_menuitem_box) + copy_menuitem.connect( + "activate", lambda i: self.copy_section(this_section) + ) + copy_menuitem.show() + menu.append(copy_menuitem) + if ( + metomi.rose.variable.IGNORED_BY_USER + in self.sections[this_section].ignored_reason + ): + # Enable section. + enab_menuitem_box = Gtk.Box() + enab_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_YES, Gtk.IconSize.MENU + ) + enab_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_ENABLE # noqa: E501 + ) + enab_menuitem = Gtk.MenuItem() + enab_menuitem_box.pack_start( + enab_menuitem_icon, False, False, 0 + ) + enab_menuitem_box.pack_start( + enab_menuitem_label, False, False, 0 + ) + Gtk.Container.add(enab_menuitem, enab_menuitem_box) + enab_menuitem.connect( + "activate", + lambda i: self.sub_ops.ignore_section( + this_section, False + ), + ) + enab_menuitem.show() + menu.append(enab_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, enab_menuitem) + ) + else: + # Ignore section. + ign_menuitem_box = Gtk.Box() + ign_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_NO, Gtk.IconSize.MENU + ) + ign_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_IGNORE # noqa: E501 + ) + ign_menuitem = Gtk.MenuItem() + ign_menuitem_box.pack_start( + ign_menuitem_icon, False, False, 0 + ) + ign_menuitem_box.pack_start( + ign_menuitem_label, False, False, 0 + ) + Gtk.Container.add(ign_menuitem, ign_menuitem_box) + ign_menuitem.connect( + "activate", + lambda i: self.sub_ops.ignore_section( + this_section, True + ), + ) + ign_menuitem.show() + menu.append(ign_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, ign_menuitem) + ) + # Remove section. + rem_menuitem_box = Gtk.Box() + rem_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + rem_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_REMOVE # noqa: E501 + ) + rem_menuitem = Gtk.MenuItem() + rem_menuitem_box.pack_start(rem_menuitem_icon, False, False, 0) + rem_menuitem_box.pack_start( + rem_menuitem_label, False, False, 0 + ) + Gtk.Container.add(rem_menuitem, rem_menuitem_box) + rem_menuitem.connect( + "activate", lambda i: self.remove_section(this_section) + ) + rem_menuitem.show() + menu.append(rem_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_REMOVE, rem_menuitem) + ) + else: # A group is currently selected. + # Ignore all + ign_menuitem_box = Gtk.Box() + ign_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_NO, Gtk.IconSize.MENU + ) + ign_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_IGNORE # noqa: E501 + ) + ign_menuitem = Gtk.MenuItem() + ign_menuitem_box.pack_start(ign_menuitem_icon, False, False, 0) + ign_menuitem_box.pack_start( + ign_menuitem_label, False, False, 0 + ) + Gtk.Container.add(ign_menuitem, ign_menuitem_box) + ign_menuitem.connect( + "activate", self._ignore_selected_sections, True + ) + ign_menuitem.show() + menu.append(ign_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, ign_menuitem) + ) + # Enable all + ign_menuitem_box = Gtk.Box() + ign_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_YES, Gtk.IconSize.MENU + ) + ign_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_ENABLE # noqa: E501 + ) + ign_menuitem = Gtk.MenuItem() + ign_menuitem_box.pack_start(ign_menuitem_icon, False, False, 0) + ign_menuitem_box.pack_start( + ign_menuitem_label, False, False, 0 + ) + Gtk.Container.add(ign_menuitem, ign_menuitem_box) + ign_menuitem.connect( + "activate", self._ignore_selected_sections, False + ) + ign_menuitem.show() + menu.append(ign_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_IGNORE, ign_menuitem) + ) + # Delete all. + rem_menuitem_box = Gtk.Box() + rem_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + rem_menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_MENU_REMOVE # noqa: E501 + ) + rem_menuitem = Gtk.MenuItem() + rem_menuitem_box.pack_start(rem_menuitem_icon, False, False, 0) + rem_menuitem_box.pack_start( + rem_menuitem_label, False, False, 0 + ) + Gtk.Container.add(rem_menuitem, rem_menuitem_box) + rem_menuitem.connect( + "activate", self._remove_selected_sections + ) + rem_menuitem.show() + menu.append(rem_menuitem) + shortcuts.append( + (metomi.rose.config_editor.ACCEL_REMOVE, rem_menuitem) + ) + + # list shortcut keys + accel = Gtk.AccelGroup() + menu.set_accel_group(accel) + for key_press, menuitem in shortcuts: + key, mod = Gtk.accelerator_parse(key_press) + menuitem.add_accelerator( + "activate", accel, key, mod, Gtk.AccelFlags.VISIBLE + ) + menu.popup_at_pointer(event) + menu.show_all() + return False + + def add_section(self, section=None, opt_map=None): + """Add a new section. + + section is the optional name for the new section - otherwise + one will be calculated, if the sub data sections are duplicates + opt_map is a dictionary of option names and values to add with + the section + + """ + if section is None: + if not self.sections or not self.is_duplicate: + return False + section_base = list(self.sections.keys())[0].rsplit("(", 1)[0] + i = 1 + section = section_base + "(" + str(i) + ")" + while section in self.sections: + i += 1 + section = section_base + "(" + str(i) + ")" + self.sub_ops.add_section(section, opt_map=opt_map) + self.scroll_to_section(section) + return section + + def copy_section(self, section): + """Copy a section and its content into a new section name.""" + new_section = self.sub_ops.clone_section(section) + self.scroll_to_section(new_section) + + def _handle_key_press_event(self, treeview, event): + if event.keyval == Gdk.KEY_Delete: + # `Delete` - remove section(s) + self._remove_selected_sections() + # detect key combination + elif "GDK_CONTROL_MASK" in event.get_state().value_names: + # `Ctrl + ?` + if event.keyval == Gdk.KEY_i: + # `Ctrl + i` - ignore section(s) + self._ignore_selected_sections(None) + + def _remove_selected_sections(self, *args): + """Remove any sections currently selected by the user.""" + sections = self._get_selected_sections() + self.sub_ops.remove_sections(sections) + self._view.get_selection().unselect_all() + + def _ignore_selected_sections(self, _, ignore=None): + """Ignores any sections currently selected by the user. Ignore mode is + set by the 'ignore' kwarg: + True: Ignore all sections (if not already ignored) + False: Enable all sections (if not already enabled) + None: Ignore all sections, if sections are all already ignored + then enable all sections inserted. + """ + sections_ = self._get_selected_sections() + ignored = [ + metomi.rose.variable.IGNORED_BY_USER + in self.sections[section_].ignored_reason + for section_ in sections_ + ] + # If ignore mode is not specified decide whether to ignore or enable. + if ignore is None: + ignore = not all(ignored) + # Filter out sections that are already ignored/enabled. + sections_ = [ + section_ + for section_, ignored in zip(sections_, ignored) + if ignored != ignore + ] + self.sub_ops.ignore_sections( + sections_, ignore, skip_sub_data_update=False + ) + + def remove_section(self, section): + """Remove a section.""" + self.sub_ops.remove_section(section) + + def scroll_to_section(self, section): + """Find a particular section in the treeview and scroll to it.""" + iter_ = self.get_section_iter(section) + if iter_ is not None: + path = self._view.get_model().get_path(iter_) + self._view.scroll_to_cell(path) + self._view.set_cursor(path) + + def get_section_iter(self, section): + """Get the Gtk.TreeIter of this section.""" + iters = [] + sect_index = self.get_section_column_index() + self._view.get_model().foreach( + self._check_value_iter, [sect_index, section, iters] + ) + if iters: + return iters[0] + return None + + def _check_value_iter(self, model, path, iter_, data): + value_index, value, iters = data + if model.get_value(iter_, value_index) == value: + iters.append(iter_) + return True + return False + + def _sort_row_data(self, row1, row2): + return self.sort_util.cmp_(row1[0], row2[0]) + + def _handle_group_change(self, combobox): + model = combobox.get_model() + col_name = model.get_value(combobox.get_active_iter(), 0) + if col_name: + group_index = self.column_names.index(col_name) + # Any existing grouping changes the order of self.column_names. + if ( + self.group_index is not None + and group_index <= self.group_index + ): + group_index -= 1 + else: + group_index = None + if group_index == self.group_index: + return False + self.group_index = group_index + self.update() + return False + + def _apply_grouping( + self, data_rows, column_names, group_index=None, descending=False + ): + rows_are_descendants = [] + if group_index is None: + return data_rows, column_names, rows_are_descendants + k = group_index + data_rows = [r[k : k + 1] + r[0:k] + r[k + 1 :] for r in data_rows] + column_names.insert(0, column_names.pop(k)) + if descending: + data_rows.sort(key=cmp_to_key(self._sort_row_data), reverse=True) + else: + data_rows.sort(key=cmp_to_key(self._sort_row_data)) + last_entry = None + rows_are_descendants = [] + for i, row in enumerate(data_rows): + if i > 0 and last_entry == row[0]: + rows_are_descendants.append(True) + else: + rows_are_descendants.append(False) + last_entry = row[0] + return data_rows, column_names, rows_are_descendants + + +class StandardSummaryDataPanel(BaseSummaryDataPanel): + """Class that provides a standard interface to summary data.""" + + def add_cell_renderer_for_value(self, col, col_title): + """Add a CellRendererText for the column.""" + cell_for_value = Gtk.CellRendererText() + col.pack_start(cell_for_value, True) + col.set_cell_data_func(cell_for_value, self._set_tree_cell_value) + + def set_tree_cell_status(self, col, cell, model, row_iter, _): + """Set the status text for a cell in this column.""" + col_index = self._view.get_columns().index(col) + sect_index = self.get_section_column_index() + section = model.get_value(row_iter, sect_index) + if section is None: + cell.set_property("markup", None) + return False + if col_index == sect_index: + node_data = self.sections.get(section) + else: + option = self.column_names[col_index] + id_ = self.util.get_id_from_section_option(section, option) + node_data = self.var_id_map.get(id_) + cell.set_property("markup", self.get_status_from_data(node_data)) + + def get_model_data(self): + """Construct a data model of other page data.""" + sub_sect_names = list(self.sections.keys()) + sub_var_names = [] + self.var_id_map = {} + for section, variables in list(self.variables.items()): + for variable in variables: + self.var_id_map[variable.metadata["id"]] = variable + if variable.name not in sub_var_names: + sub_var_names.append(variable.name) + sub_sect_names.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + sub_var_names.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + data_rows = [] + for section in sub_sect_names: + row_data = [section] + for opt in sub_var_names: + id_ = self.util.get_id_from_section_option(section, opt) + var = self.var_id_map.get(id_) + if var is None: + row_data.append(None) + else: + row_data.append(metomi.rose.gtk.util.safe_str(var.value)) + data_rows.append(row_data) + if self.is_duplicate: + sect_name = ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_INDEX_TITLE + ) + else: + sect_name = ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_SECTION_TITLE + ) + column_names = [sect_name] + column_names += sub_var_names + return data_rows, column_names + + def _set_tree_cell_value(self, column, cell, treemodel, iter_, _): + cell.set_property("visible", True) + col_index = self._view.get_columns().index(column) + value = self._view.get_model().get_value(iter_, col_index) + max_len = metomi.rose.config_editor.SUMMARY_DATA_PANEL_MAX_LEN + if value is not None and len(value) > max_len and col_index != 0: + cell.set_property("width-chars", max_len) + cell.set_property("ellipsize", Pango.EllipsizeMode.END) + sect_index = self.get_section_column_index() + if value is not None and col_index == sect_index and self.is_duplicate: + value = value.split("(")[-1].rstrip(")") + if col_index == 0 and treemodel.iter_parent(iter_) is not None: + cell.set_property("visible", False) + cell.set_property("markup", value) + + def set_tree_tip(self, view, row_iter, col_index, tip): + """Set the hover-over (Tooltip) text for the TreeView.""" + sect_index = self.get_section_column_index() + section = view.get_model().get_value(row_iter, sect_index) + if section is None: + return False + if col_index == sect_index: + option = None + if section not in self.sections: + return False + id_data = self.sections[section] + tip_text = section + else: + option = self.column_names[col_index] + id_ = self.util.get_id_from_section_option(section, option) + if id_ not in self.var_id_map: + return False + id_data = self.var_id_map[id_] + value = str(view.get_model().get_value(row_iter, col_index)) + tip_text = metomi.rose.CONFIG_DELIMITER.join( + [section, option, value] + ) + tip_text += id_data.metadata.get(metomi.rose.META_PROP_DESCRIPTION, "") + if tip_text: + tip_text += "\n" + for key, value in list(id_data.error.items()): + tip_text += ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_ERROR_TIP.format( + key, value + ) + ) + for key in id_data.ignored_reason: + tip_text += key + "\n" + if option is not None: + change_text = self.var_ops.get_var_changes(id_data) + tip_text += change_text + "\n" + tip.set_text(tip_text.rstrip()) + return True + + def get_section_column_index(self): + """Return the column index for the section name.""" + sect_index = 0 + if self.group_index is not None and self.group_index != 0: + sect_index = 1 + return sect_index diff --git a/metomi/rose/config_editor/plugin/__init__.py b/metomi/rose/config_editor/plugin/__init__.py new file mode 100644 index 0000000000..ea1a3150ab --- /dev/null +++ b/metomi/rose/config_editor/plugin/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- diff --git a/metomi/rose/config_editor/plugin/um/__init__.py b/metomi/rose/config_editor/plugin/um/__init__.py new file mode 100644 index 0000000000..ea1a3150ab --- /dev/null +++ b/metomi/rose/config_editor/plugin/um/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- diff --git a/metomi/rose/config_editor/plugin/um/widget/__init__.py b/metomi/rose/config_editor/plugin/um/widget/__init__.py new file mode 100644 index 0000000000..ea1a3150ab --- /dev/null +++ b/metomi/rose/config_editor/plugin/um/widget/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- diff --git a/metomi/rose/config_editor/plugin/um/widget/stash.py b/metomi/rose/config_editor/plugin/um/widget/stash.py new file mode 100644 index 0000000000..9839ce0217 --- /dev/null +++ b/metomi/rose/config_editor/plugin/um/widget/stash.py @@ -0,0 +1,1014 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import ast +import os + +from gi.repository import Pango +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config +import metomi.rose.config_editor.panelwidget.summary_data +import metomi.rose.config_tree +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util +import metomi.rose.reporter +import metomi.rose.resource + +import metomi.rose.config_editor.plugin.um.widget.stash_add as stash_add +import metomi.rose.config_editor.plugin.um.widget.stash_util as stash_util + +from functools import cmp_to_key + + +class BaseStashSummaryDataPanelv1( + metomi.rose.config_editor.panelwidget.summary_data.BaseSummaryDataPanel +): + """This is a base class for displaying and editing STASH requests. + + It adds editing capability for option values, displays metadata + fetched from the STASHmaster file, and can launch a custom dialog + for adding/removing STASH requests. + + Subclasses *must* provide the following method: + def get_stashmaster_lookup_dict(self): + which should return a nested dictionary containing STASHmaster file + information. + + Subclasses *must* override the STASH_PACKAGE_PATH attribute with an + absolute path to a directory containing a rose-app.conf file with + STASH request package information. + + Subclasses should override the STASHMASTER_PATH attribute with an + absolute path to a directory containing e.g. the STASHmaster_A + file. An argument to the widget metadata option can also be used to + provide this information. + + Subclasses should override the STASHMASTER_META_PATH attribute with + an absolute path to a directory containing a + 'STASHmaster-meta.conf' file that provides metadata for STASHmaster + fields and values. + + """ + + # These attributes must/should be overridden: + STASH_PACKAGE_PATH = None + STASHMASTER_PATH = None + STASHMASTER_META_PATH = None + + # This attribute may be overridden, if necessary: + STASHMASTER_META_FILENAME = "STASHmaster-meta.conf" + + # These attributes are generic titles. + ADD_NEW_STASH_LABEL = "New" + ADD_NEW_STASH_TIP = "Launch a window for adding new STASH requests" + ADD_NEW_STASH_WINDOW_TITLE = "Add new STASH requests" + DESCRIPTION_TITLE = "Info" + INCLUDED_TITLE = "Incl?" + PACKAGE_MANAGER_LABEL = "Packages" + PACKAGE_MANAGER_TIP = "Launch a menu for managing groups of requests" + SECTION_INDEX_TITLE = "Index" + VIEW_MANAGER_LABEL = "View" + VIEW_MANAGER_TIP = "Change view options" + + # The title property name for a request (must match the parser's one). + STASH_PARSE_DESC_OPT = "name" + + # These attributes are namelist/UM-input specific. + STREQ_NL_BASE = "namelist:streq" + STREQ_NL_SECT_OPT = "isec" + STREQ_NL_ITEM_OPT = "item" + STREQ_NL_PACKAGE_OPT = "package" + OPTION_NL_MAP = { + "dom_name": "namelist:domain", + "tim_name": "namelist:time", + "use_name": "namelist:use", + } + + def __init__(self, *args, **kwargs): + self.stashmaster_directory_path = kwargs.get("arg_str", "") + if not self.stashmaster_directory_path: + self.stashmaster_directory_path = self.STASHMASTER_PATH + self.load_stash() + super(BaseStashSummaryDataPanelv1, self).__init__(*args, **kwargs) + self._add_new_diagnostic_launcher() + self._diag_panel = None + + def get_stashmaster_lookup_dict(self): + """Return a nested dictionary with STASHmaster info. + + Record properties are stored under section_number => + item_number => property_name. + + For example, if the nested dictionary is called 'stash_dict': + stash_dict[section_number][item_number]['name'] + would be something like: + "U COMPNT OF WIND AFTER TIMESTEP" + + Subclasses must provide (override) this method. + The attribute self.stashmaster_directory_path may be used. + + """ + raise NotImplementedError() + + def add_cell_renderer_for_value(self, col, col_title): + """(Override) Add a cell renderer type based on the column.""" + self._update_available_profiles() + if col_title in self.OPTION_NL_MAP: + cell_for_value = Gtk.CellRendererCombo() + listmodel = Gtk.ListStore(str) + values = sorted(self._available_profile_map[col_title]) + for possible_value in values: + listmodel.append([possible_value]) + cell_for_value.set_property("has-entry", False) + cell_for_value.set_property("editable", True) + cell_for_value.set_property("model", listmodel) + cell_for_value.set_property("text-column", 0) + cell_for_value.connect( + "changed", self._handle_cell_combo_change, col_title + ) + col.pack_start(cell_for_value, True) + col.set_cell_data_func( + cell_for_value, self._set_tree_cell_value_combo + ) + elif col_title == self.INCLUDED_TITLE: + cell_for_value = Gtk.CellRendererToggle() + col.pack_start(cell_for_value, False) + cell_for_value.set_property("activatable", True) + cell_for_value.connect("toggled", self._handle_cell_toggle_change) + col.set_cell_data_func( + cell_for_value, self._set_tree_cell_value_toggle + ) + else: + cell_for_value = Gtk.CellRendererText() + col.pack_start(cell_for_value, True) + if col_title not in [ + self.SECTION_INDEX_TITLE, + self.DESCRIPTION_TITLE, + ]: + cell_for_value.set_property("editable", True) + cell_for_value.connect( + "edited", self._handle_cell_text_change, col_title + ) + col.set_cell_data_func(cell_for_value, self._set_tree_cell_value) + + def get_model_data(self): + """(Override) Construct a data model of other page data.""" + sub_sect_names = list(self.sections.keys()) + sub_var_names = [] + self.var_id_map = {} + section_sort_keys = {} + # Apply the correct default sorting (section, item) + for section, variables in list(self.variables.items()): + for variable in variables: + self.var_id_map[variable.metadata["id"]] = variable + if variable.name not in sub_var_names: + sub_var_names.append(variable.name) + if variable.name == self.STREQ_NL_SECT_OPT: + section_sort_keys.setdefault(section, []) + try: + value = int(variable.value) + except (TypeError, ValueError): + value = variable.value + if len(section_sort_keys[section]) < 1: + section_sort_keys[section].append(None) + section_sort_keys[section][0] = value + if variable.name == self.STREQ_NL_ITEM_OPT: + section_sort_keys.setdefault(section, []) + try: + value = int(variable.value) + except (TypeError, ValueError): + value = variable.value + while len(section_sort_keys[section]) < 2: + section_sort_keys[section].append(None) + section_sort_keys[section][1] = value + if variable.name == self.STREQ_NL_PACKAGE_OPT: + section_sort_keys.setdefault(section, []) + while len(section_sort_keys[section]) < 3: + section_sort_keys[section].append(None) + section_sort_keys[section][2] = variable.value + for section, sort_list in list(section_sort_keys.items()): + while len(sort_list) < 4: + sort_list.append(None) + sort_list[3] = section + sub_sect_names.sort(key=lambda x: section_sort_keys.get(x)) + sub_var_names.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + sub_var_names.sort( + key=cmp_to_key( + lambda x, y: (y != self.STREQ_NL_PACKAGE_OPT) + - (x != self.STREQ_NL_PACKAGE_OPT) + ) + ) + sub_var_names.sort( + key=cmp_to_key( + lambda x, y: (y == self.STREQ_NL_ITEM_OPT) + - (x == self.STREQ_NL_ITEM_OPT) + ) + ) + sub_var_names.sort( + key=cmp_to_key( + lambda x, y: (y == self.STREQ_NL_SECT_OPT) + - (x == self.STREQ_NL_SECT_OPT) + ) + ) + # Load the data. + data_rows = [] + for section in sub_sect_names: + row_data = [] + stash_sect_id = self.util.get_id_from_section_option( + section, self.STREQ_NL_SECT_OPT + ) + stash_item_id = self.util.get_id_from_section_option( + section, self.STREQ_NL_ITEM_OPT + ) + sect_var = self.var_id_map.get(stash_sect_id) + item_var = self.var_id_map.get(stash_item_id) + stash_props = None + if sect_var is not None and item_var is not None: + stash_props = self._stash_lookup.get(sect_var.value, {}).get( + item_var.value + ) + if stash_props is None: + row_data.append(None) + else: + desc = stash_props[self.STASH_PARSE_DESC_OPT].strip() + row_data.append(desc) + is_enabled = ( + metomi.rose.variable.IGNORED_BY_USER + not in self.sections[section].ignored_reason + ) + row_data.append(str(is_enabled)) + for opt in sub_var_names: + id_ = self.util.get_id_from_section_option(section, opt) + var = self.var_id_map.get(id_) + if var is None: + row_data.append(None) + else: + row_data.append(metomi.rose.gtk.util.safe_str(var.value)) + row_data.append(section) + data_rows.append(row_data) + # Set the column names and their ordering. + column_names = [self.DESCRIPTION_TITLE, self.INCLUDED_TITLE] + column_names += sub_var_names + [self.SECTION_INDEX_TITLE] + return data_rows, column_names + + def get_section_column_index(self): + """(Override) Return the column index for the section (Rose section)""" + return self.column_names.index(self.SECTION_INDEX_TITLE) + + def get_stashmaster_meta_map(self): + """Return a nested dictionary with STASHmaster metadata. + + This stores metadata about STASHmaster fields and their values. + Field metadata is stored as field_name => metadata_property => + metadata_value. Field value metadata (for a particular value of + a field) is under (field_name + "=" + value) => + metadata_property => metadata_value. + + For example, if the nested dictionary is called + 'stash_meta_dict': + stash_meta_dict["grid"]["title"] + would be something like: + "Grid code" + and: + stash_meta_dict["grid=2"]["description"] + would be something like: + "A grid code of 2 means...." + + """ + if self.STASHMASTER_META_PATH is None: + return {} + try: + config = ( + metomi.rose.config_tree.ConfigTreeLoader() + .load( + self.STASHMASTER_META_PATH, self.STASHMASTER_META_FILENAME + ) + .node + ) + except (metomi.rose.config.ConfigSyntaxError, IOError, OSError) as exc: + metomi.rose.reporter.Reporter()( + "Error loading STASHmaster metadata resource: " + + type(exc).__name__ + + ": " + + str(exc) + + "\n", + kind=metomi.rose.reporter.Reporter.KIND_ERR, + level=metomi.rose.reporter.Reporter.FAIL, + ) + return {} + stash_meta_dict = {} + for keys, node in config.walk(no_ignore=True): + if len(keys) == 2: + address = keys[0].replace("stashmaster:", "", 1) + prop = keys[1] + stash_meta_dict.setdefault(address, {}) + stash_meta_dict[address][prop] = node.value + return stash_meta_dict + + def set_tree_cell_status(self, col, cell, model, row_iter, _): + """(Override) Set the status-related markup for a cell.""" + col_index = self._view.get_columns().index(col) + sect_index = self.get_section_column_index() + section = model.get_value(row_iter, sect_index) + if section is None: + return cell.set_property("markup", None) + if ( + col_index == sect_index + or self.column_names[col_index] == self.DESCRIPTION_TITLE + ): + node_data = self.sections.get(section) + else: + option = self.column_names[col_index] + if option is None: + return cell.set_property("markup", None) + id_ = self.util.get_id_from_section_option(section, option) + node_data = self.var_id_map.get(id_) + cell.set_property("markup", self.get_status_from_data(node_data)) + + def set_tree_tip(self, view, row_iter, col_index, tip): + """(Override) Set the TreeView Tooltip.""" + sect_index = self.get_section_column_index() + model = view.get_model() + section = model.get_value(row_iter, sect_index) + if section is None: + return False + col_name = self.column_names[col_index] + stash_section_index = self.column_names.index(self.STREQ_NL_SECT_OPT) + stash_item_index = self.column_names.index(self.STREQ_NL_ITEM_OPT) + stash_section = model.get_value(row_iter, stash_section_index) + stash_item = model.get_value(row_iter, stash_item_index) + if col_index == sect_index or col_name in [ + self.DESCRIPTION_TITLE, + self.INCLUDED_TITLE, + ]: + option = None + if section not in self.sections: + return False + id_data = self.sections[section] + if col_index == sect_index: + tip_text = section + elif col_name in [self.DESCRIPTION_TITLE, self.INCLUDED_TITLE]: + tip_text = str(model.get_value(row_iter, col_index)) + tip_text += "\n" + section + if col_name == self.DESCRIPTION_TITLE: + value = str(model.get_value(row_iter, col_index)) + metadata = stash_util.get_stash_section_meta( + self._stashmaster_meta_lookup, + stash_section, + stash_item, + value, + ) + help_ = metadata.get(metomi.rose.META_PROP_HELP) + if help_ is not None: + tip_text += "\n\n" + help_ + else: + option = self.column_names[col_index] + id_ = self.util.get_id_from_section_option(section, option) + if id_ not in self.var_id_map: + tip.set_text(str(model.get_value(row_iter, col_index))) + return True + id_data = self.var_id_map[id_] + value = str(model.get_value(row_iter, col_index)) + tip_text = ( + metomi.rose.CONFIG_DELIMITER.join([section, option, value]) + + "\n" + ) + if option in self.OPTION_NL_MAP and option in list( + self._profile_location_map.keys() + ): + profile_id = self._profile_location_map[option].get(value) + if profile_id is not None: + profile_sect = self.util.get_section_option_from_id( + profile_id + )[0] + tip_text += "See " + profile_sect + tip_text += id_data.metadata.get(metomi.rose.META_PROP_DESCRIPTION, "") + if tip_text: + tip_text += "\n" + for key, value in list(id_data.error.items()): + tip_text += ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_ERROR_TIP.format( + key, value + ) + ) + for key in id_data.ignored_reason: + tip_text += "({0})\n".format(key) + if option is None: + change_text = self.sect_ops.get_section_changes(id_data) + else: + change_text = self.var_ops.get_var_changes(id_data) + if change_text: + tip_text += change_text + "\n" + tip.set_text(tip_text.rstrip()) + return True + + def _get_custom_menu_items(self, path, col, event): + """(Override) Add some custom menu items to the TreeView menu.""" + menuitems = [] + model = self._view.get_model() + col_index = self._view.get_columns().index(col) + col_title = self.column_names[col_index] + if ( + col_title not in self.OPTION_NL_MAP + and col_title != self.DESCRIPTION_TITLE + ): + return [] + iter_ = model.get_iter(path) + value = model.get_value(iter_, col_index) + if col_title == self.DESCRIPTION_TITLE: + meta_key = self.STASH_PARSE_DESC_OPT + "=" + str(value) + metadata = self._stashmaster_meta_lookup.get(meta_key, {}) + help_ = metadata.get(metomi.rose.META_PROP_HELP) + if help_ is not None: + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_HELP, Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label(label="Help") + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + menuitem._help_text = help_ + menuitem._help_title = "Help for %s" % value + menuitem.connect("activate", self._launch_record_help) + menuitem.show() + return [menuitem] + return [] + if value not in self._profile_location_map[col_title]: + return [] + location = self._profile_location_map[col_title][value] + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_ABOUT, Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label(label="View " + value.strip("'")) + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + menuitem._loc_id = location + menuitem.connect("activate", lambda i: self.search_function(i._loc_id)) + menuitem.show() + menuitems.append(menuitem) + profiles_menuitems = [] + for profile in self._available_profile_map[col_title]: + label = "View " + profile.strip("'") + menuitem = Gtk.MenuItem(label=label) + menuitem._loc_id = self._profile_location_map[col_title][profile] + menuitem.connect( + "button-release-event", + lambda i, e: self.search_function(i._loc_id), + ) + menuitem.show() + profiles_menuitems.append(menuitem) + if profiles_menuitems: + profiles_menu = Gtk.Menu() + profiles_menu.show() + profiles_root_menuitem_box = Gtk.Box() + profiles_root_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_ABOUT, Gtk.IconSize.MENU + ) + profiles_root_menuitem_label = Gtk.Label(label="View...") + profiles_root_menuitem = Gtk.MenuItem() + profiles_root_menuitem_box.pack_start( + profiles_root_menuitem_icon, False, False, 0 + ) + profiles_root_menuitem_box.pack_start( + profiles_root_menuitem_label, False, False, 0 + ) + Gtk.Container.add( + profiles_root_menuitem, profiles_root_menuitem_box + ) + profiles_root_menuitem.show() + profiles_root_menuitem.set_submenu(profiles_menu) + for profiles_menuitem in profiles_menuitems: + profiles_menu.append(profiles_menuitem) + menuitems.append(profiles_root_menuitem) + return menuitems + + def add_new_stash_request(self, section, item, launch_dialog=False): + """Add a new streq namelist.""" + new_opt_map = { + self.STREQ_NL_SECT_OPT: section, + self.STREQ_NL_ITEM_OPT: item, + } + new_section = self.add_section(None, opt_map=new_opt_map) + if launch_dialog: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_INFO, + "Added request as {0}".format(new_section), + "New Request", + ) + + def generate_package_lookup(self): + """Store a dictionary of package requests and domains.""" + self._package_lookup = {} + self._package_profile_lookup = {} + for sect, node in list(self.package_config.value.items()): + if not isinstance(node.value, dict) or node.is_ignored(): + continue + base_sect = sect.rsplit("(", 1)[0] + if base_sect == self.STREQ_NL_BASE: + package_node = node.get( + [self.STREQ_NL_PACKAGE_OPT], no_ignore=True + ) + if package_node is not None: + package = package_node.value + self._package_lookup.setdefault(package, {}) + self._package_lookup[package].setdefault(base_sect, []) + self._package_lookup[package][base_sect].append(sect) + for profile in self.OPTION_NL_MAP: + profile_node = node.get([profile], no_ignore=True) + if profile_node is not None: + self._package_lookup[package].setdefault( + profile, [] + ) + self._package_lookup[package][profile].append( + profile_node.value + ) + continue + for profile, profile_nl in list(self.OPTION_NL_MAP.items()): + if base_sect == profile_nl: + name_node = node.get([profile], no_ignore=True) + if name_node is not None: + name = name_node.value + self._package_profile_lookup.setdefault(profile, {}) + self._package_profile_lookup[profile][name] = sect + break + + def load_stash(self): + """Load a STASHmaster file into data structures for later use.""" + self._stash_lookup = self.get_stashmaster_lookup_dict() + package_config_file = os.path.join( + self.STASH_PACKAGE_PATH, metomi.rose.SUB_CONFIG_NAME + ) + self.package_config = metomi.rose.config.ConfigNode() + metomi.rose.config.ConfigLoader().load_with_opts( + package_config_file, self.package_config + ) + self.generate_package_lookup() + self._stashmaster_meta_lookup = self.get_stashmaster_meta_map() + + def _add_new_diagnostic_launcher(self): + # Create a button for launching the "Add new STASH" dialog. + self._add_button = metomi.rose.gtk.util.CustomButton( + label=self.ADD_NEW_STASH_LABEL, + stock_id=Gtk.STOCK_ADD, + tip_text=self.ADD_NEW_STASH_TIP, + ) + package_button = metomi.rose.gtk.util.CustomButton( + label=self.PACKAGE_MANAGER_LABEL, + tip_text=self.PACKAGE_MANAGER_TIP, + has_menu=True, + ) + self.control_widget_hbox.pack_end( + package_button, expand=False, fill=False, padding=0 + ) + self.control_widget_hbox.pack_end( + self._add_button, expand=False, fill=False, padding=0 + ) + self._add_button.connect("clicked", self._launch_new_diagnostic_window) + package_button.connect("button-press-event", self._package_menu_launch) + + def _handle_activation(self, view, path, column): + # React to row activation in the TreeView. + if path is None: + return False + model = view.get_model() + row_iter = model.get_iter(path) + col_index = view.get_columns().index(column) + col_title = self.column_names[col_index] + if col_title in self.OPTION_NL_MAP: + return False + cell_data = model.get_value(row_iter, col_index) + sect_index = self.get_section_column_index() + section = model.get_value(row_iter, sect_index) + if section is None: + return False + option = None + if col_index != sect_index and cell_data is not None: + option = self.column_names[col_index] + if option == self.DESCRIPTION_TITLE: + option = None + id_ = self.util.get_id_from_section_option(section, option) + self.search_function(id_) + + def _handle_cell_combo_change( + self, combo_cell, path_string, new, col_title + ): + # Handle a Gtk.CellRendererCombo (variable) value change. + if isinstance(new, str): + new_value = new + else: + new_value = combo_cell.get_property("model").get_value(new, 0) + row_iter = self._view.get_model().get_iter(path_string) + sect_index = self.get_section_column_index() + section = self._view.get_model().get_value(row_iter, sect_index) + option = col_title + id_ = self.util.get_id_from_section_option(section, option) + var = self.var_id_map[id_] + self.var_ops.set_var_value(var, new_value) + return False + + def _handle_cell_text_change( + self, text_cell, path_string, new_text, col_title + ): + # Handle a Gtk.CellRendererText (variable) value change. + row_iter = self._view.get_model().get_iter(path_string) + sect_index = self.get_section_column_index() + section = self._view.get_model().get_value(row_iter, sect_index) + option = col_title + id_ = self.util.get_id_from_section_option(section, option) + var = self.var_id_map[id_] + self.var_ops.set_var_value(var, new_text) + return False + + def _handle_cell_toggle_change(self, combo_cell, path_string): + # Handle a Gtk.CellRendererToggle value change. + was_active = combo_cell.get_property("active") + row_iter = self._view.get_model().get_iter(path_string) + sect_index = self.get_section_column_index() + section = self._view.get_model().get_value(row_iter, sect_index) + if section is None: + return False + is_active = not was_active + combo_cell.set_property("active", is_active) + self.sub_ops.ignore_section(section, not is_active) + return False + + def _get_request_lookup(self): + # Return a lookup dictionary of streq info. + request_lookup = {} + for section in self.sections: + stash_sect_id = self.util.get_id_from_section_option( + section, self.STREQ_NL_SECT_OPT + ) + stash_item_id = self.util.get_id_from_section_option( + section, self.STREQ_NL_ITEM_OPT + ) + sect_var = self.var_id_map.get(stash_sect_id) + item_var = self.var_id_map.get(stash_item_id) + if sect_var is None or item_var is None: + continue + st_sect = sect_var.value + st_item = item_var.value + request_lookup.setdefault(st_sect, {}) + request_lookup[st_sect].setdefault(st_item, {}) + request_lookup[st_sect][st_item][section] = {} + for variable in self.variables.get(section, []): + request_lookup[st_sect][st_item][section].update( + {variable.name: variable.value} + ) + return request_lookup + + def _get_request_changes(self): + # Return a list of request indices with changes. + changed_requests = {} + for section, sect_data in list(self.sections.items()): + changes = self.sect_ops.get_section_changes(sect_data) + if changes: + changed_requests.update({section: changes}) + return changed_requests + + def _handle_close_diagnostic_window(self, widget=None): + # Handle a close of the diagnostic window. + self._diag_panel = None + self._add_button.set_sensitive(True) + + def _launch_new_diagnostic_window(self, widget=None): + # Launch the "new STASH request" dialog. + window = Gtk.Window() + window.set_title(self.ADD_NEW_STASH_WINDOW_TITLE) + request_lookup = self._get_request_lookup() + request_changes = self._get_request_changes() + add_new_func = lambda s, i: ( + self.add_new_stash_request(s, i, launch_dialog=True) + ) + self._diag_panel = stash_add.AddStashDiagnosticsPanelv1( + self._stash_lookup, + request_lookup, + request_changes, + self._stashmaster_meta_lookup, + add_new_func, + self.scroll_to_section, + self._refresh_diagnostic_window, + ) + window.add(self._diag_panel) + window.set_default_size(900, 800) + window.connect("destroy", self._handle_close_diagnostic_window) + window.show() + self._add_button.set_sensitive(False) + + def _launch_record_help(self, menuitem): + """Launch the help from a menu.""" + metomi.rose.gtk.dialog.run_scrolled_dialog( + menuitem._help_text, menuitem._help_title + ) + + def _refresh_diagnostic_window(self): + # Refresh information in the "new STASH request" dialog. + if self._diag_panel is not None: + request_lookup = self._get_request_lookup() + request_changes = self._get_request_changes() + self._diag_panel.update_request_info( + request_lookup, request_changes + ) + + def _set_tree_cell_value_combo(self, column, cell, treemodel, iter_, _): + # Extract a value for a combo box cell renderer. + cell.set_property("visible", True) + cell.set_property("editable", True) + col_index = self._view.get_columns().index(column) + value = self._view.get_model().get_value(iter_, col_index) + if value is None: + cell.set_property("editable", False) + cell.set_property("text", None) + cell.set_property("visible", False) + if col_index == 0 and treemodel.iter_parent(iter_) is not None: + cell.set_property("visible", False) + cell.set_property("text", value) + + def _set_tree_cell_value_toggle(self, column, cell, treemodel, iter_, _): + # Extract a value for a toggle cell renderer. + cell.set_property("visible", True) + col_index = self._view.get_columns().index(column) + value = self._view.get_model().get_value(iter_, col_index) + if value is None: + cell.set_property("visible", False) + if col_index == 0 and treemodel.iter_parent(iter_) is not None: + cell.set_property("visible", False) + try: + value = ast.literal_eval(value) + except ValueError: + return False + cell.set_property("active", value) + + def _set_tree_cell_value(self, column, cell, treemodel, iter_, _): + # Extract a value for a conventional text cell renderer. + cell.set_property("visible", True) + col_index = self._view.get_columns().index(column) + value = self._view.get_model().get_value(iter_, col_index) + if value is None: + cell.set_property("markup", None) + cell.set_property("visible", False) + max_len = metomi.rose.config_editor.SUMMARY_DATA_PANEL_MAX_LEN + if value is not None and len(value) > max_len and col_index != 0: + cell.set_property("width-chars", max_len) + cell.set_property("ellipsize", Pango.EllipsizeMode.END) + sect_index = self.get_section_column_index() + if value is not None and col_index == sect_index and self.is_duplicate: + value = value.split("(")[-1].rstrip(")") + if col_index == 0 and treemodel.iter_parent(iter_) is not None: + cell.set_property("visible", False) + cell.set_property("markup", metomi.rose.gtk.util.safe_str(value)) + + def _update_available_profiles(self): + # Retrieve which profiles (namelists like domain) are available. + self._available_profile_map = {} + self._profile_location_map = {} + ok_var_names = list(self.OPTION_NL_MAP.keys()) + ok_sect_names = list(self.OPTION_NL_MAP.values()) + for name in ok_var_names: + self._available_profile_map[name] = [] + for id_, value in list(self.sub_ops.get_var_id_values().items()): + section, option = self.util.get_section_option_from_id(id_) + if option in ok_var_names and any( + section.startswith(n) for n in ok_sect_names + ): + self._profile_location_map.setdefault(option, {}) + self._profile_location_map[option].update({value: id_}) + self._available_profile_map.setdefault(option, []) + self._available_profile_map[option].append(value) + for profile_names in list(self._available_profile_map.values()): + profile_names.sort() + + def _package_add(self, package): + # Add a package of new requests, and profiles if needed. + sections_for_adding = [] + for sect_type, values in list(self._package_lookup[package].items()): + if sect_type == self.STREQ_NL_BASE: + sections_for_adding.extend(values) + else: + for profile_name in values: + sect = self._package_profile_lookup[sect_type].get( + profile_name + ) + sections_for_adding.append(sect) + sections_for_adding = sorted(set(sections_for_adding)) + for section in sections_for_adding: + opt_name_values = {} + node = self.package_config.get([section], no_ignore=True) + if node is None or not isinstance(node.value, dict): + continue + for opt, node in list(node.value.items()): + opt_name_values.update({opt: node.value}) + if section not in self.sections: + self.sub_ops.add_section(section, opt_map=opt_name_values) + + def _package_menu_launch(self, widget, event): + # Create a menu below the widget for package actions. + menu = Gtk.Menu() + packages = {} + for section, vars_ in list(self.variables.items()): + for var in vars_: + if var.name == self.STREQ_NL_PACKAGE_OPT: + is_ignored = ( + metomi.rose.variable.IGNORED_BY_USER + in self.sections[section].ignored_reason + ) + packages.setdefault(var.value, []) + packages[var.value].append(is_ignored) + for package in sorted(packages.keys()): + ignored_list = packages[package] + package_title = "Package: " + package + package_menuitem = Gtk.MenuItem(package_title) + package_menuitem.show() + package_menu = Gtk.Menu() + enable_menuitem_box = Gtk.Box() + enable_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_YES, Gtk.IconSize.MENU + ) + enable_menuitem_label = Gtk.Label(label="Enable all") + enable_menuitem = Gtk.MenuItem() + enable_menuitem_box.pack_start( + enable_menuitem_icon, False, False, 0 + ) + enable_menuitem_box.pack_start( + enable_menuitem_label, False, False, 0 + ) + Gtk.Container.add(enable_menuitem, enable_menuitem_box) + enable_menuitem._connect_args = (package, False) + enable_menuitem.connect( + "button-release-event", + lambda m, e: self._packages_enable(*m._connect_args), + ) + enable_menuitem.show() + enable_menuitem.set_sensitive(any(ignored_list)) + package_menu.append(enable_menuitem) + ignore_menuitem_box = Gtk.Box() + ignore_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_NO, Gtk.IconSize.MENU + ) + ignore_menuitem_label = Gtk.Label(label="Ignore all") + ignore_menuitem = Gtk.MenuItem() + ignore_menuitem_box.pack_start( + ignore_menuitem_icon, False, False, 0 + ) + ignore_menuitem_box.pack_start( + ignore_menuitem_label, False, False, 0 + ) + Gtk.Container.add(ignore_menuitem, ignore_menuitem_box) + ignore_menuitem._connect_args = (package, True) + ignore_menuitem.connect( + "button-release-event", + lambda m, e: self._packages_enable(*m._connect_args), + ) + ignore_menuitem.set_sensitive(any(not i for i in ignored_list)) + ignore_menuitem.show() + package_menu.append(ignore_menuitem) + remove_menuitem_box = Gtk.Box() + remove_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + remove_menuitem_label = Gtk.Label(label="Remove all") + remove_menuitem = Gtk.MenuItem() + remove_menuitem_box.pack_start( + remove_menuitem_icon, False, False, 0 + ) + remove_menuitem_box.pack_start( + remove_menuitem_label, False, False, 0 + ) + Gtk.Container.add(remove_menuitem, remove_menuitem_box) + remove_menuitem._connect_args = (package,) + remove_menuitem.connect( + "button-release-event", + lambda m, e: self._packages_remove(*m._connect_args), + ) + remove_menuitem.show() + package_menu.append(remove_menuitem) + package_menuitem.set_submenu(package_menu) + menu.append(package_menuitem) + package_menu.show_all() + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_ADD, Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label(label="Import") + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + import_menu = Gtk.Menu() + new_packages = set(self._package_lookup.keys()) - set(packages.keys()) + for new_package in sorted(new_packages): + new_pack_menuitem = Gtk.MenuItem(label=new_package) + new_pack_menuitem._connect_args = (new_package,) + new_pack_menuitem.connect( + "button-release-event", + lambda m, e: self._package_add(*m._connect_args), + ) + new_pack_menuitem.show() + import_menu.append(new_pack_menuitem) + if not new_packages: + menuitem.set_sensitive(False) + menuitem.set_submenu(import_menu) + menuitem.show() + menu.append(menuitem) + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_NO, Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label(label="Disable all packages") + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + menuitem.connect( + "activate", lambda i: self._packages_enable(disable=True) + ) + menuitem.show() + menu.append(menuitem) + menu.popup_at_widget( + widget, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + + def _packages_remove(self, only_this_package=None): + # Remove requests and no-longer-needed profiles for packages. + self._update_available_profiles() + sections_for_removing = [] + profile_streqs = {} + for section, vars_ in list(self.variables.items()): + for var in vars_: + if var.name == self.STREQ_NL_PACKAGE_OPT: + if ( + only_this_package is None + or var.value == only_this_package + ): + sect = self.util.get_section_option_from_id( + var.metadata["id"] + )[0] + if sect not in sections_for_removing: + sections_for_removing.append(sect) + elif var.name in self.OPTION_NL_MAP: + profile_streqs.setdefault(var.name, {}) + profile_streqs[var.name].setdefault(var.value, []) + profile_streqs[var.name][var.value].append(section) + streq_remove_list = list(sections_for_removing) + for profile_type in profile_streqs: + for name, streq_list in list(profile_streqs[profile_type].items()): + if all([s in streq_remove_list for s in streq_list]): + # This is only referenced by sections about to be removed. + profile_id = self._profile_location_map.get( + profile_type, {} + ).get(name) + if profile_id is None: + continue + profile_section = self.util.get_section_option_from_id( + profile_id + )[0] + sections_for_removing.append(profile_section) + self.sub_ops.remove_sections(sections_for_removing) + + def _packages_enable(self, only_this_package=None, disable=False): + """Enable or user-ignore requests matching these packages.""" + sections_for_changing = [] + for vars_ in list(self.variables.values()): + for var in vars_: + if var.name == self.STREQ_NL_PACKAGE_OPT: + if ( + only_this_package is None + or var.value == only_this_package + ): + sect = self.util.get_section_option_from_id( + var.metadata["id"] + )[0] + if sect not in sections_for_changing: + is_ignored = ( + metomi.rose.variable.IGNORED_BY_USER + in self.sections[sect].ignored_reason + ) + if is_ignored != disable: + sections_for_changing.append(sect) + self.sub_ops.ignore_sections(sections_for_changing, disable) diff --git a/metomi/rose/config_editor/plugin/um/widget/stash_add.py b/metomi/rose/config_editor/plugin/um/widget/stash_add.py new file mode 100644 index 0000000000..4ceb445170 --- /dev/null +++ b/metomi/rose/config_editor/plugin/um/widget/stash_add.py @@ -0,0 +1,791 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +from gi.repository import Pango +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.gtk.util + +import metomi.rose.config_editor.plugin.um.widget.stash_util as stash_util + +from functools import cmp_to_key + + +class AddStashDiagnosticsPanelv1(Gtk.Box): + """Display a grouped set of stash requests to add.""" + + STASH_PARSE_DESC_OPT = "name" + STASH_PARSE_ITEM_OPT = "item" + STASH_PARSE_SECT_OPT = "sectn" + + def __init__( + self, + stash_lookup, + request_lookup, + changed_request_lookup, + stash_meta_lookup, + add_stash_request_func, + navigate_to_stash_request_func, + refresh_stash_requests_func, + ): + """Create a widget displaying STASHmaster information. + + stash_lookup is a nested dictionary that uses STASH section + numbers and item numbers as a key chain to get the information + about a specific record - e.g. stash_lookup[1][0]["name"] may + return the 'name' (text description) for stash section 1, item + 0. + + request_lookup is a nested dictionary in the same form as stash + lookup (section numbers, item numbers), but then contains + a dictionary of relevant streq namelists vs option-value pairs + as a sub-level - e.g. request_lookup[1][0].keys() gives all the + relevant streq indices for stash section 1, item 0. + request_lookup[1][0]["0abcd123"]["dom_name"] may give the + domain profile name for the relevant namelist:streq(0abcd123). + + changed_request_lookup is a dictionary of changed streq + namelists (keys) and their change description text (values). + + stash_meta_lookup is a dictionary of STASHmaster property + names (keys) with value-metadata-dict key-value pairs (values). + To extract the metadata dict for a 'grid' value of "2", look + at stash_meta_lookup["grid=2"] which should be a dict of normal + Rose metadata key-value pairs such as: + {"description": "2 means Something something"}. + + add_stash_request_func is a hook function that should take a + STASH section number argument and a STASH item number argument, + and add this request as a new namelist in a configuration. + + navigate_to_stash_request_func is a hook function that should + take a streq namelist section id and search for it. It should + display it if found. + + refresh_stash_requests_func is a hook function that should call + the update_request_info method with updated streq namelist + info. + + """ + super(AddStashDiagnosticsPanelv1, self).__init__( + self, orientation=Gtk.Orientation.VERTICAL + ) + self.set_property("homogeneous", False) + self.stash_lookup = stash_lookup + self.request_lookup = request_lookup + self.changed_request_lookup = changed_request_lookup + self.stash_meta_lookup = stash_meta_lookup + self._add_stash_request = add_stash_request_func + self.navigate_to_stash_request = navigate_to_stash_request_func + self.refresh_stash_requests = refresh_stash_requests_func + self.group_index = 0 + self._visible_metadata_columns = ["Section"] + + # Automatically hide columns which have fixed-value metadata. + self._hidden_column_names = [] + for key, metadata in list(self.stash_meta_lookup.items()): + if "=" in key: + continue + values_string = metadata.get(metomi.rose.META_PROP_VALUES, "0, 1") + if len(metomi.rose.variable.array_split(values_string)) == 1: + self._hidden_column_names.append(key) + + self._should_show_meta_column_titles = False + self.control_widget_hbox = self._get_control_widget_hbox() + self.pack_start( + self.control_widget_hbox, expand=False, fill=False, padding=0 + ) + self._view = metomi.rose.gtk.util.TooltipTreeView( + get_tooltip_func=self.set_tree_tip + ) + self._view.set_rules_hint(True) + self.sort_util = metomi.rose.gtk.util.TreeModelSortUtil( + self._view.get_model, 2 + ) + self._view.show() + self._view.connect( + "button-press-event", self._handle_button_press_event + ) + self._view.connect("cursor-changed", self._update_control_sensitivity) + self._window = Gtk.ScrolledWindow() + self._window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + self.generate_tree_view(is_startup=True) + self._window.add(self._view) + self._window.show() + self.pack_start(self._window, expand=True, fill=True, padding=0) + self._update_control_sensitivity() + self.show() + + def add_cell_renderer_for_value(self, column): + """Add a cell renderer to represent the model value.""" + cell_for_value = Gtk.CellRendererText() + column.pack_start(cell_for_value, True) + column.set_cell_data_func(cell_for_value, self._set_tree_cell_value) + + def add_stash_request(self, section, item): + """Handle an add stash request call.""" + self._add_stash_request(section, item) + self.refresh_stash_requests() + + def generate_tree_view(self, is_startup=False): + """Create the summary of page data.""" + for column in self._view.get_columns(): + self._view.remove_column(column) + self._view.set_model(self.get_tree_model()) + for i, column_name in enumerate(self.column_names): + col = Gtk.TreeViewColumn() + if column_name in self._hidden_column_names: + col.set_visible(False) + col_title = column_name.replace("_", "__") + if self._should_show_meta_column_titles: + col_meta = self.stash_meta_lookup.get(column_name, {}) + title = col_meta.get(metomi.rose.META_PROP_TITLE) + if title is not None: + col_title = title + col.set_title(col_title) + self.add_cell_renderer_for_value(col) + if i < len(self.column_names) - 1: + col.set_resizable(True) + col.set_sort_column_id(i) + self._view.append_column(col) + if is_startup: + group_model = Gtk.TreeStore(str) + group_model.append(None, [""]) + for i, name in enumerate(self.column_names): + if name not in ["?", "#"]: + group_model.append(None, [name]) + self._group_widget.set_model(group_model) + self._group_widget.set_active(self.group_index + 1) + self._group_widget.connect("changed", self._handle_group_change) + self.update_request_info() + + def get_model_data_and_columns(self): + """Return a list of data tuples and columns""" + data_rows = [] + columns = ["Section", "Item", "Description", "?", "#"] + sections = list(self.stash_lookup.keys()) + sections.sort(key=cmp_to_key(self.sort_util.cmp_)) + props_excess = [ + self.STASH_PARSE_DESC_OPT, + self.STASH_PARSE_ITEM_OPT, + self.STASH_PARSE_SECT_OPT, + ] + for section in sections: + if section == "-1": + continue + items = list(self.stash_lookup[section].keys()) + items.sort(key=cmp_to_key(self.sort_util.cmp_)) + for item in items: + data = self.stash_lookup[section][item] + this_row = [section, item, data[self.STASH_PARSE_DESC_OPT]] + this_row += ["", ""] + for prop in sorted(data.keys()): + if prop not in props_excess: + this_row.append(data[prop]) + if prop not in columns: + columns.append(prop) + data_rows.append(this_row) + return data_rows, columns + + def get_tree_model(self): + """Construct a data model of other page data.""" + data_rows, cols = self.get_model_data_and_columns() + data_rows, cols, rows_are_descendants = self._apply_grouping( + data_rows, cols, self.group_index + ) + self.column_names = cols + if data_rows: + col_types = [str] * len(data_rows[0]) + else: + col_types = [] + self._store = Gtk.TreeStore(*col_types) + parent_iter = None + for i, row_data in enumerate(data_rows): + if rows_are_descendants is None: + self._store.append(None, row_data) + elif rows_are_descendants[i]: + self._store.append(parent_iter, row_data) + else: + parent_data = [row_data[0]] + [None] * len(row_data[1:]) + parent_iter = self._store.append(None, parent_data) + self._store.append(parent_iter, row_data) + filter_model = self._store.filter_new() + filter_model.set_visible_func(self._filter_visible) + sort_model = Gtk.TreeModelSort(filter_model) + for i in range(len(self.column_names)): + sort_model.set_sort_func(i, self.sort_util.sort_column, i) + sort_model.connect( + "sort-column-changed", self.sort_util.handle_sort_column_change + ) + return sort_model + + def set_tree_tip(self, treeview, row_iter, col_index, tip): + """Add the hover-over text for a cell to 'tip'. + + treeview is the Gtk.TreeView object + row_iter is the Gtk.TreeIter for the row + col_index is the index of the Gtk.TreeColumn in + e.g. treeview.get_columns() + tip is the Gtk.Tooltip object that the text needs to be set in. + + """ + model = treeview.get_model() + stash_section_index = self.column_names.index("Section") + stash_item_index = self.column_names.index("Item") + stash_desc_index = self.column_names.index("Description") + stash_request_num_index = self.column_names.index("#") + stash_section = model.get_value(row_iter, stash_section_index) + stash_item = model.get_value(row_iter, stash_item_index) + stash_desc = model.get_value(row_iter, stash_desc_index) + stash_request_num = model.get_value(row_iter, stash_request_num_index) + if not stash_request_num or stash_request_num == "0": + stash_request_num = "None" + name = self.column_names[col_index] + value = model.get_value(row_iter, col_index) + help_ = None + if value is None: + return False + if name == "?": + name = "Requests Status" + if ( + value + == metomi.rose.config_editor.SUMMARY_DATA_PANEL_MODIFIED_MARKUP + ): + value = "changed" + else: + value = "no changes" + elif name == "#": + name = "Requests" + if stash_request_num != "None": + sect_streqs = self.request_lookup.get(stash_section, {}) + streqs = list(sect_streqs.get(stash_item, {}).keys()) + streqs.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + if streqs: + value = "\n " + "\n ".join(streqs) + else: + value = stash_request_num + " total" + if name == "Section": + meta_key = self.STASH_PARSE_SECT_OPT + "=" + value + elif name == "Description": + metadata = stash_util.get_stash_section_meta( + self.stash_meta_lookup, stash_section, stash_item, value + ) + help_ = metadata.get(metomi.rose.META_PROP_HELP) + meta_key = self.STASH_PARSE_DESC_OPT + "=" + value + else: + meta_key = name + "=" + value + value_meta = self.stash_meta_lookup.get(meta_key, {}) + title = value_meta.get(metomi.rose.META_PROP_TITLE, "") + if help_ is None: + help_ = value_meta.get(metomi.rose.META_PROP_HELP, "") + if title and not help_: + value += "\n" + title + if help_: + value += "\n" + metomi.rose.gtk.util.safe_str(help_) + text = name + ": " + str(value) + "\n\n" + text += "Section: " + str(stash_section) + "\n" + text += "Item: " + str(stash_item) + "\n" + text += "Description: " + str(stash_desc) + "\n" + if stash_request_num != "None": + text += str(stash_request_num) + " request(s)" + text = text.strip() + tip.set_text(text) + return True + + def update_request_info( + self, request_lookup=None, changed_request_lookup=None + ): + """Refresh streq namelist information.""" + if request_lookup is not None: + self.request_lookup = request_lookup + if changed_request_lookup is not None: + self.changed_request_lookup = changed_request_lookup + sect_col_index = self.column_names.index("Section") + item_col_index = self.column_names.index("Item") + streq_info_index = self.column_names.index("?") + num_streqs_index = self.column_names.index("#") + # For speed, pass in the relevant indices here. + user_data = ( + sect_col_index, + item_col_index, + streq_info_index, + num_streqs_index, + ) + self._store.foreach(self._update_row_request_info, user_data) + # Loop over any parent rows and sum numbers and info. + parent_iter = self._store.iter_children(None) + while parent_iter is not None: + num_streq_children = 0 + streq_info_children = "" + child_iter = self._store.iter_children(parent_iter) + if child_iter is None: + parent_iter = self._store.iter_next(parent_iter) + continue + while child_iter is not None: + num = self._store.get_value(child_iter, num_streqs_index) + info = self._store.get_value(child_iter, streq_info_index) + if isinstance(num, str) and num.isdigit(): + num_streq_children += int(num) + if info and not streq_info_children: + streq_info_children = info + child_iter = self._store.iter_next(child_iter) + self._store.set_value( + parent_iter, num_streqs_index, str(num_streq_children) + ) + self._store.set_value( + parent_iter, streq_info_index, streq_info_children + ) + parent_iter = self._store.iter_next(parent_iter) + + def _update_row_request_info(self, model, path, iter_, user_data): + # Update the streq namelist information for a model row. + ( + sect_col_index, + item_col_index, + streq_info_index, + num_streqs_index, + ) = user_data + section = model.get_value(iter_, sect_col_index) + item = model.get_value(iter_, item_col_index) + if section is None or item is None: + model.set_value(iter_, num_streqs_index, None) + model.set_value(iter_, streq_info_index, None) + return + streqs = self.request_lookup.get(section, {}).get(item, {}) + model.set_value(iter_, num_streqs_index, str(len(streqs))) + streq_info = "" + mod_markup = ( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_MODIFIED_MARKUP + ) + for streq_section in streqs: + if streq_section in self.changed_request_lookup: + streq_info = mod_markup + streq_info + break + model.set_value(iter_, streq_info_index, streq_info) + + def _append_row_data(self, model, path, iter_, data_rows): + # Append new row data. + data_rows.append(model.get(iter_)) + + def _apply_grouping( + self, data_rows, column_names, group_index=None, descending=False + ): + # Calculate nesting (grouping) for the data. + rows_are_descendants = None + if group_index is None: + return data_rows, column_names, rows_are_descendants + k = group_index + data_rows = [r[k : k + 1] + r[0:k] + r[k + 1 :] for r in data_rows] + column_names.insert(0, column_names.pop(k)) + if descending: + data_rows.sort(key=cmp_to_key(self._sort_row_data), reverse=True) + else: + data_rows.sort(key=cmp_to_key(self._sort_row_data)) + last_entry = None + rows_are_descendants = [] + for i, row in enumerate(data_rows): + if i > 0 and last_entry == row[0]: + rows_are_descendants.append(True) + else: + rows_are_descendants.append(False) + last_entry = row[0] + return data_rows, column_names, rows_are_descendants + + def _filter_refresh(self, widget=None): + # Hook function that reacts to a change in filter status. + self._view.get_model().get_model().refilter() + + def _filter_visible(self, model, iter_, _): + # This returns whether a row should be visible. + filt_text = self._filter_widget.get_text() + if not filt_text: + return True + for col_text in model.get(iter_, *list(range(len(self.column_names)))): + if ( + isinstance(col_text, str) + and filt_text.lower() in col_text.lower() + ): + return True + child_iter = model.iter_children(iter_) + while child_iter is not None: + if self._filter_visible(model, child_iter): + return True + child_iter = model.iter_next(child_iter) + return False + + def _get_control_widget_hbox(self): + # Build the control widgets for the dialog. + filter_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_FILTER_LABEL + ) + filter_label.show() + self._filter_widget = Gtk.Entry() + self._filter_widget.set_width_chars( + metomi.rose.config_editor.SUMMARY_DATA_PANEL_FILTER_MAX_CHAR + ) + self._filter_widget.connect("changed", self._filter_refresh) + self._filter_widget.set_tooltip_text("Filter by literal values") + self._filter_widget.show() + group_label = Gtk.Label( + label=metomi.rose.config_editor.SUMMARY_DATA_PANEL_GROUP_LABEL + ) + group_label.show() + self._group_widget = Gtk.ComboBox() + cell = Gtk.CellRendererText() + self._group_widget.pack_start(cell, True) + self._group_widget.add_attribute(cell, "text", 0) + self._group_widget.show() + self._add_button = metomi.rose.gtk.util.CustomButton( + label="Add", + stock_id=Gtk.STOCK_ADD, + tip_text="Add a new request for this entry", + ) + self._add_button.connect( + "activate", lambda b: self._handle_add_current_row() + ) + self._add_button.connect( + "clicked", lambda b: self._handle_add_current_row() + ) + self._refresh_button = metomi.rose.gtk.util.CustomButton( + label="Refresh", + stock_id=Gtk.STOCK_REFRESH, + tip_text="Refresh namelist:streq statuses", + ) + self._refresh_button.connect( + "activate", lambda b: self.refresh_stash_requests() + ) + self._refresh_button.connect( + "clicked", lambda b: self.refresh_stash_requests() + ) + self._view_button = metomi.rose.gtk.util.CustomButton( + label="View", tip_text="Select view options", has_menu=True + ) + self._view_button.connect("button-press-event", self._popup_view_menu) + filter_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + filter_hbox.pack_start( + group_label, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_start( + self._group_widget, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_start( + filter_label, expand=False, fill=False, padding=10 + ) + filter_hbox.pack_start( + self._filter_widget, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_end( + self._view_button, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_end( + self._refresh_button, expand=False, fill=False, padding=0 + ) + filter_hbox.pack_end( + self._add_button, expand=False, fill=False, padding=0 + ) + filter_hbox.show() + return filter_hbox + + def _get_current_section_item(self): + """Return the current highlighted section and item.""" + current_path = self._view.get_cursor()[0] + if current_path is None: + return (None, None) + current_iter = self._view.get_model().get_iter(current_path) + return self._get_section_item_from_iter(current_iter) + + def _get_section_item_col_indices(self): + """Return the column indices of the STASH section and item.""" + sect_index = 0 + if self.group_index is not None and self.group_index != sect_index: + sect_index = 1 + item_index = 1 + if self.group_index is not None: + if self.group_index == 0: + item_index = 1 + elif self.group_index == 1: + item_index = 0 + else: + item_index = 2 + return sect_index, item_index + + def _get_section_item_from_iter(self, iter_): + """Return the STASH section and item numbers for this row.""" + sect_index, item_index = self._get_section_item_col_indices() + model = self._view.get_model() + section = model.get_value(iter_, sect_index) + item = model.get_value(iter_, item_index) + return section, item + + def _handle_add_current_row(self): + section, item = self._get_current_section_item() + return self.add_stash_request(section, item) + + def _handle_activation(self, view, path, column): + """React to an activation of a row in the dialog.""" + model = view.get_model() + row_iter = model.get_iter(path) + section, item = self._get_section_item_from_iter(row_iter) + if section is None or item is None: + return False + return self.add_stash_request(section, item) + + def _handle_button_press_event(self, treeview, event): + """React to a button press (mouse click).""" + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is not None: + path, col = pathinfo[0:2] + if event.button != 3: + if event.type == Gdk.EventType._2BUTTON_PRESS: + self._handle_activation(treeview, path, col) + else: + self._popup_tree_menu(path, col, event) + + def _handle_group_change(self, combobox): + """Handle grouping (nesting) status changes.""" + model = combobox.get_model() + col_name = model.get_value(combobox.get_active_iter(), 0) + if col_name: + if col_name in self._hidden_column_names: + self._hidden_column_names.remove(col_name) + group_index = self.column_names.index(col_name) + # Any existing grouping changes the order of self.column_names. + if ( + self.group_index is not None + and group_index <= self.group_index + ): + group_index -= 1 + else: + group_index = None + if group_index == self.group_index: + return False + self.group_index = group_index + self.generate_tree_view() + return False + + def _launch_record_help(self, menuitem): + """Launch the help from a menu.""" + metomi.rose.gtk.dialog.run_scrolled_dialog( + menuitem._help_text, menuitem._help_title + ) + + def _popup_tree_menu(self, path, col, event): + """Launch a menu for this main treeview row.""" + menu = Gtk.Menu() + menu.show() + model = self._view.get_model() + row_iter = model.get_iter(path) + section, item = self._get_section_item_from_iter(row_iter) + if section is None or item is None: + return False + add_menuitem_box = Gtk.Box() + add_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_ADD, Gtk.IconSize.MENU + ) + add_menuitem_label = Gtk.Label(label="Add STASH request") + add_menuitem = Gtk.MenuItem() + add_menuitem_box.pack_start(add_menuitem_icon, False, False, 0) + add_menuitem_box.pack_start(add_menuitem_label, False, False, 0) + Gtk.Container.add(add_menuitem, add_menuitem_box) + add_menuitem.connect( + "activate", lambda i: self.add_stash_request(section, item) + ) + add_menuitem.show() + menu.append(add_menuitem) + stash_desc_index = self.column_names.index("Description") + stash_desc_value = model.get_value(row_iter, stash_desc_index) + desc_meta = self.stash_meta_lookup.get( + self.STASH_PARSE_DESC_OPT + "=" + str(stash_desc_value), {} + ) + desc_meta_help = desc_meta.get(metomi.rose.META_PROP_HELP) + if desc_meta_help is not None: + help_menuitem_box = Gtk.Box() + help_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_HELP, Gtk.IconSize.MENU + ) + help_menuitem_label = Gtk.Label(label="Help") + help_menuitem = Gtk.MenuItem() + help_menuitem_box.pack_start(help_menuitem_icon, False, False, 0) + help_menuitem_box.pack_start(help_menuitem_label, False, False, 0) + Gtk.Container.add(help_menuitem, help_menuitem_box) + help_menuitem._help_text = desc_meta_help + help_menuitem._help_title = "Help for %s" % stash_desc_value + help_menuitem.connect("activate", self._launch_record_help) + help_menuitem.show() + menu.append(help_menuitem) + streqs = list( + self.request_lookup.get(section, {}).get(item, {}).keys() + ) + if streqs: + view_menuitem_box = Gtk.Box() + view_menuitem_icon = Gtk.Image.new_from_icon_name( + Gtk.STOCK_FIND, Gtk.IconSize.MENU + ) + view_menuitem_label = Gtk.Label(label="View...") + view_menuitem = Gtk.MenuItem() + view_menuitem_box.pack_start(view_menuitem_icon, False, False, 0) + view_menuitem_box.pack_start(view_menuitem_label, False, False, 0) + Gtk.Container.add(view_menuitem, view_menuitem_box) + view_menuitem.show() + view_menu = Gtk.Menu() + view_menu.show() + view_menuitem.set_submenu(view_menu) + streqs.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + for streq in streqs: + view_streq_menuitem = Gtk.MenuItem(label=streq) + view_streq_menuitem._section = streq + view_streq_menuitem.connect( + "button-release-event", + lambda m, e: self.navigate_to_stash_request(m._section), + ) + view_streq_menuitem.show() + view_menu.append(view_streq_menuitem) + menu.append(view_menuitem) + menu.popup_at_widget( + event.button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + return False + + def _popup_view_menu(self, widget, event): + # Create a menu below the widget for view options. + menu = Gtk.Menu() + meta_menuitem = Gtk.CheckMenuItem(label="Show expanded value info") + if len(self.column_names) == len(self._visible_metadata_columns): + meta_menuitem.set_active(True) + meta_menuitem.connect("toggled", self._toggle_show_more_info) + meta_menuitem.show() + if not self.stash_meta_lookup: + meta_menuitem.set_sensitive(False) + menu.append(meta_menuitem) + col_title_menuitem = Gtk.CheckMenuItem( + label="Show expanded column titles" + ) + if self._should_show_meta_column_titles: + col_title_menuitem.set_active(True) + col_title_menuitem.connect( + "toggled", self._toggle_show_meta_column_titles + ) + col_title_menuitem.show() + if not self.stash_meta_lookup: + col_title_menuitem.set_sensitive(False) + menu.append(col_title_menuitem) + sep = Gtk.SeparatorMenuItem() + sep.show() + menu.append(sep) + show_column_menuitem = Gtk.MenuItem("Show/hide columns") + show_column_menuitem.show() + show_column_menu = Gtk.Menu() + show_column_menuitem.set_submenu(show_column_menu) + menu.append(show_column_menuitem) + for i, column in enumerate(self._view.get_columns()): + col_name = self.column_names[i] + col_title = col_name.replace("_", "__") + if self._should_show_meta_column_titles: + col_meta = self.stash_meta_lookup.get(col_name, {}) + title = col_meta.get(metomi.rose.META_PROP_TITLE) + if title is not None: + col_title = title + col_menuitem = Gtk.CheckMenuItem( + label=col_title, use_underline=False + ) + col_menuitem.show() + col_menuitem.set_active(column.get_visible()) + col_menuitem._connect_args = (col_name,) + col_menuitem.connect( + "toggled", + lambda c: self._toggle_show_column_name(*c._connect_args), + ) + show_column_menu.append(col_menuitem) + menu.popup_at_widget( + widget, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + + def _set_tree_cell_value(self, column, cell, treemodel, iter_, _): + # Extract an appropriate value for this cell from the model. + cell.set_property("visible", True) + col_index = self._view.get_columns().index(column) + col_title = self.column_names[col_index] + value = self._view.get_model().get_value(iter_, col_index) + if col_title in self._visible_metadata_columns and value is not None: + if col_title == "Section": + key = self.STASH_PARSE_SECT_OPT + "=" + value + else: + key = col_title + "=" + value + value_meta = self.stash_meta_lookup.get(key, {}) + title = value_meta.get(metomi.rose.META_PROP_TITLE, "") + if title: + value = title + desc = value_meta.get(metomi.rose.META_PROP_DESCRIPTION, "") + if desc: + value += ": " + desc + max_len = 36 + if value is not None and len(value) > max_len and col_index != 0: + cell.set_property("width-chars", max_len) + cell.set_property("ellipsize", Pango.EllipsizeMode.END) + if col_index == 0 and treemodel.iter_parent(iter_) is not None: + cell.set_property("visible", False) + if value is not None and col_title != "?": + value = metomi.rose.gtk.util.safe_str(value) + cell.set_property("markup", value) + + def _sort_row_data(self, row1, row2): + """Handle column sorting.""" + return self.sort_util.cmp_(row1[0], row2[0]) + + def _toggle_show_column_name(self, column_name): + """Handle a show/hide of a particular column.""" + col_index = self.column_names.index(column_name) + column = self._view.get_columns()[col_index] + if column.get_visible(): + return column.set_visible(False) + return column.set_visible(True) + + def _toggle_show_more_info(self, widget, column_name=None): + """Handle a show/hide of extra information.""" + should_show = widget.get_active() + if column_name is None: + column_names = self.column_names + else: + column_names = [column_name] + for name in column_names: + if should_show: + if name not in self._visible_metadata_columns: + self._visible_metadata_columns.append(name) + elif name in self._visible_metadata_columns: + if name != "Section": + self._visible_metadata_columns.remove(name) + self._view.columns_autosize() + + def _toggle_show_meta_column_titles(self, widget): + self._should_show_meta_column_titles = widget.get_active() + self.generate_tree_view() + + def _update_control_sensitivity(self, _=None): + section, item = self._get_current_section_item() + self._add_button.set_sensitive( + section is not None and item is not None + ) diff --git a/metomi/rose/config_editor/plugin/um/widget/stash_util.py b/metomi/rose/config_editor/plugin/um/widget/stash_util.py new file mode 100644 index 0000000000..07f398bfee --- /dev/null +++ b/metomi/rose/config_editor/plugin/um/widget/stash_util.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This holds some shared functionality between stash and stash_add.""" + + +def get_stash_section_meta( + stash_meta_lookup, stash_section, stash_item, stash_description +): + """Return a dictionary of metadata properties for this stash record.""" + try: + stash_code = 1000 * int(stash_section) + int(stash_item) + except (TypeError, ValueError): + return {} + meta_key = "code(%d)" % stash_code + return stash_meta_lookup.get(meta_key, {}) diff --git a/metomi/rose/config_editor/stack.py b/metomi/rose/config_editor/stack.py new file mode 100644 index 0000000000..b2e4445aba --- /dev/null +++ b/metomi/rose/config_editor/stack.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor + + +class StackItem(object): + """A dictionary containing stack information.""" + + def __init__( + self, + page_label, + action_text, + node, + undo_function, + undo_args=None, + group=None, + custom_name=None, + ): + self.page_label = page_label + self.action = action_text + self.node = node + if custom_name is None: + self.name = self.node.name + else: + self.name = custom_name + self.group = group + if hasattr(self.node, "value"): + self.value = self.node.value + self.old_value = self.node.old_value + else: + self.value = "" + self.old_value = "" + self.undo_func = undo_function + if undo_args is None: + undo_args = [] + self.undo_args = undo_args + + def __repr__(self): + return ( + self.action[0].lower() + + self.action[1:] + + " " + + self.name + + ", ".join([str(u) for u in self.undo_args]) + ) + + +class StackViewer(Gtk.Window): + """Window to dynamically display the internal stack.""" + + def __init__(self, undo_stack, redo_stack, undo_func): + """Load a view of the stack.""" + super(StackViewer, self).__init__() + self.set_title(metomi.rose.config_editor.STACK_VIEW_TITLE) + self.action_colour_map = { + metomi.rose.config_editor.STACK_ACTION_ADDED: ( + metomi.rose.config_editor.COLOUR_STACK_ADDED + ), + metomi.rose.config_editor.STACK_ACTION_APPLIED: ( + metomi.rose.config_editor.COLOUR_STACK_APPLIED + ), + metomi.rose.config_editor.STACK_ACTION_CHANGED: ( + metomi.rose.config_editor.COLOUR_STACK_CHANGED + ), + metomi.rose.config_editor.STACK_ACTION_CHANGED_COMMENTS: ( + metomi.rose.config_editor.COLOUR_STACK_CHANGED_COMMENTS + ), + metomi.rose.config_editor.STACK_ACTION_ENABLED: ( + metomi.rose.config_editor.COLOUR_STACK_ENABLED + ), + metomi.rose.config_editor.STACK_ACTION_IGNORED: ( + metomi.rose.config_editor.COLOUR_STACK_IGNORED + ), + metomi.rose.config_editor.STACK_ACTION_REMOVED: ( + metomi.rose.config_editor.COLOUR_STACK_REMOVED + ), + metomi.rose.config_editor.STACK_ACTION_REVERSED: ( + metomi.rose.config_editor.COLOUR_STACK_REVERSED + ), + } + self.undo_func = undo_func + self.undo_stack = undo_stack + self.redo_stack = redo_stack + self.set_border_width(metomi.rose.config_editor.SPACING_SUB_PAGE) + self.main_vbox = Gtk.VPaned() + accelerators = Gtk.AccelGroup() + accel_key, accel_mods = Gtk.accelerator_parse("Z") + accelerators.connect( + accel_key, + accel_mods, + Gtk.AccelFlags.VISIBLE, + lambda a, b, c, d: self.undo_from_log(), + ) + accel_key, accel_mods = Gtk.accelerator_parse("Z") + accelerators.connect( + accel_key, + accel_mods, + Gtk.AccelFlags.VISIBLE, + lambda a, b, c, d: self.undo_from_log(redo_mode_on=True), + ) + self.add_accel_group(accelerators) + self.set_default_size(*metomi.rose.config_editor.SIZE_STACK) + self.undo_view = self.get_stack_view(redo_mode_on=False) + self.redo_view = self.get_stack_view(redo_mode_on=True) + undo_vbox = self.get_stack_view_box(self.undo_view, redo_mode_on=False) + redo_vbox = self.get_stack_view_box(self.redo_view, redo_mode_on=True) + self.main_vbox.pack1(undo_vbox, resize=True, shrink=True) + self.main_vbox.show() + self.main_vbox.pack2(redo_vbox, resize=False, shrink=True) + self.main_vbox.show() + self.undo_view.connect("size-allocate", self.scroll_view) + self.redo_view.connect("size-allocate", self.scroll_view) + self.add(self.main_vbox) + self.show() + + def get_stack_view_box(self, log_buffer, redo_mode_on=False): + """Return a frame containing a scrolled text view.""" + text_view = log_buffer + text_scroller = Gtk.ScrolledWindow() + text_scroller.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS + ) + text_scroller.set_shadow_type(Gtk.ShadowType.IN) + text_scroller.add(text_view) + vadj = text_scroller.get_vadjustment() + vadj.set_value(vadj.get_upper() - 0.9 * vadj.get_page_size()) + text_scroller.show() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + label = Gtk.Label() + if redo_mode_on: + label.set_text("REDO STACK") + self.redo_text_view = text_view + else: + label.set_text("UNDO STACK") + self.undo_text_view = text_view + label.show() + vbox.set_border_width(metomi.rose.config_editor.SPACING_SUB_PAGE) + vbox.pack_start( + label, + expand=False, + fill=True, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + vbox.pack_start(text_scroller, expand=True, fill=True, padding=0) + vbox.show() + return vbox + + def undo_from_log(self, redo_mode_on=False): + """Drive the main program undo function and update.""" + self.undo_func(redo_mode_on) + self.update() + + def get_stack_view(self, redo_mode_on=False): + """Return a tree view with information from a stack.""" + stack_model = self.get_stack_model(redo_mode_on, make_new_model=True) + stack_view = Gtk.TreeView(stack_model) + columns = {} + cell_text = {} + for title in [ + metomi.rose.config_editor.STACK_COL_NS, + metomi.rose.config_editor.STACK_COL_ACT, + metomi.rose.config_editor.STACK_COL_NAME, + metomi.rose.config_editor.STACK_COL_VALUE, + metomi.rose.config_editor.STACK_COL_OLD_VALUE, + ]: + columns[title] = Gtk.TreeViewColumn() + columns[title].set_title(title) + cell_text[title] = Gtk.CellRendererText() + columns[title].pack_start(cell_text[title], True) + columns[title].add_attribute( + cell_text[title], + attribute="markup", + column=len(list(columns.keys())) - 1, + ) + stack_view.append_column(columns[title]) + stack_view.show() + return stack_view + + def get_stack_model(self, redo_mode_on=False, make_new_model=False): + """Return a Gtk.ListStore generated from a stack.""" + stack = [self.undo_stack, self.redo_stack][redo_mode_on] + if make_new_model: + model = Gtk.ListStore(str, str, str, str, str, bool) + else: + model = [self.undo_view.get_model(), self.redo_view.get_model()][ + redo_mode_on + ] + model.clear() + for stack_item in stack: + marked_up_action = stack_item.action + if stack_item.action in self.action_colour_map: + colour = self.action_colour_map[stack_item.action] + marked_up_action = ( + "" + + stack_item.action + + "" + ) + if stack_item.page_label is None: + short_label = "None" + else: + short_label = re.sub("^/[^/]+/", "", stack_item.page_label) + model.append( + ( + short_label, + marked_up_action, + stack_item.name, + repr(stack_item.value), + repr(stack_item.old_value), + False, + ) + ) + return model + + def scroll_view(self, tree_view, event=None): + """Scroll the parent scrolled window to the bottom.""" + vadj = tree_view.get_parent().get_vadjustment() + if vadj.get_upper() > vadj.get_lower() + vadj.get_page_size(): + vadj.set_value(vadj.get_upper() - 0.95 * vadj.get_page_size()) + + def update(self): + """Reload text views from the undo and redo stacks.""" + if self.undo_text_view.get_parent() is None: + return + self.get_stack_model(redo_mode_on=False) + self.get_stack_model(redo_mode_on=True) diff --git a/metomi/rose/config_editor/status.py b/metomi/rose/config_editor/status.py new file mode 100644 index 0000000000..189e650175 --- /dev/null +++ b/metomi/rose/config_editor/status.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import datetime +import sys +import time + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.gtk.console +import metomi.rose.reporter + + +class StatusReporter(metomi.rose.reporter.Reporter): + """Handle event notification. + + load_updater must be a metomi.rose.gtk.splash.SplashScreenProcess + instance (or have the same interface to update and stop methods). + + status_bar_update_func must be a function that accepts a + metomi.rose.reporter.Event, a metomi.rose.reporter kind-of-event string, + and a level of importance/verbosity. + See metomi.rose.reporter for more details. + + """ + + EVENT_KIND_LOAD = "load" + + def __init__(self, load_updater, status_bar_update_func): + self._load_updater = load_updater + self._status_bar_update_func = status_bar_update_func + self._no_load = False + + def event_handler( + self, message, kind=None, level=None, prefix=None, clip=None + ): + """Handle a message or event.""" + message_kwargs = {} + if isinstance(message, metomi.rose.reporter.Event): + if kind is None: + kind = message.kind + if level is None: + level = message.level + message_kwargs = message.kwargs + if kind == self.EVENT_KIND_LOAD and not self._no_load: + ret = self._load_updater.update(str(message), **message_kwargs) + return ret + return self._status_bar_update_func(message, kind, level) + + def report_load_event( + self, text, no_progress=False, new_total_events=None + ): + """Report a load-related event + (to metomi.rose.gtk.util.SplashScreen). + + """ + + event = metomi.rose.reporter.Event( + text, + kind=self.EVENT_KIND_LOAD, + no_progress=no_progress, + new_total_events=new_total_events, + ) + self.report(event) + + def set_no_load(self): + self._no_load = True + + def stop(self): + """Stop the updater.""" + self._load_updater.stop() + + +class StatusBar(Gtk.Box): + """Generate the status bar widget.""" + + def __init__(self, verbosity=metomi.rose.reporter.Reporter.DEFAULT): + super(StatusBar, self).__init__(orientation=Gtk.Orientation.VERTICAL) + self.verbosity = verbosity + self.num_errors = 0 + self.console = None + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.show() + self.pack_start(hbox, expand=False, fill=False, padding=0) + self._generate_error_widget() + hbox.pack_start( + self._error_widget, expand=False, fill=False, padding=0 + ) + vsep_message = Gtk.VSeparator() + vsep_message.show() + vsep_eb = Gtk.EventBox() + vsep_eb.show() + hbox.pack_start(vsep_message, expand=False, fill=False, padding=0) + hbox.pack_start(vsep_eb, expand=True, fill=True, padding=0) + self._generate_message_widget() + hbox.pack_end( + self._message_widget, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + self.messages = [] + self.show() + + def set_message(self, message, kind=None, level=None): + if isinstance(message, metomi.rose.reporter.Event): + if kind is None: + kind = message.kind + if level is None: + level = message.level + print(message.level) + if level is not None: + if level > self.verbosity: + return + if isinstance(message, Exception): + kind = metomi.rose.reporter.Reporter.KIND_ERR + level = metomi.rose.reporter.Reporter.FAIL + self.messages.append((kind, str(message), time.time())) + if ( + len(self.messages) + > metomi.rose.config_editor.STATUS_BAR_MESSAGE_LIMIT + ): + self.messages.pop(0) + self._update_message_widget(str(message), kind=kind) + self._update_console() + while Gdk.events_pending(): + Gtk.main_iteration() + + def set_num_errors(self, new_num_errors): + """Update the number of errors.""" + if new_num_errors != self.num_errors: + self.num_errors = new_num_errors + self._update_error_widget() + while Gdk.events_pending(): + Gtk.main_iteration() + + def _generate_error_widget(self): + # Generate the error display widget. + self._error_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self._error_widget.show() + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + icon_path = locator.locate( + "etc/images/rose-config-edit/error_icon.png" + ) + image = Gtk.Image.new_from_file(str(icon_path)) + image.show() + self._error_widget.pack_start( + image, expand=False, fill=False, padding=0 + ) + self._error_widget_label = Gtk.Label() + self._error_widget_label.show() + self._error_widget.pack_start( + self._error_widget_label, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + self._update_error_widget() + + def _generate_message_widget(self): + # Generate the message display widget. + self._message_widget = Gtk.EventBox() + self._message_widget.show() + message_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + message_hbox.show() + self._message_widget.add(message_hbox) + self._message_widget.connect( + "enter-notify-event", self._handle_enter_message_widget + ) + self._message_widget_error_image = Gtk.Image.new_from_stock( + Gtk.STOCK_DIALOG_ERROR, Gtk.IconSize.MENU + ) + self._message_widget_info_image = Gtk.Image.new_from_stock( + Gtk.STOCK_DIALOG_INFO, Gtk.IconSize.MENU + ) + self._message_widget_label = Gtk.Label() + self._message_widget_label.show() + vsep = Gtk.VSeparator() + vsep.show() + self._console_launcher = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_INFO, + size=Gtk.IconSize.MENU, + tip_text=metomi.rose.config_editor.STATUS_BAR_CONSOLE_TIP, + as_tool=True, + ) + self._console_launcher.connect("clicked", self._launch_console) + # None of this works anymore and needs to be set by CSS + # which does not look simple + # style = Gtk.RcStyle() + # style.xthickness = 0 + # style.ythickness = 0 + # setattr(style, "inner-border", [0, 0, 0, 0]) + # self._console_launcher.modify_style(style) + message_hbox.pack_start( + self._message_widget_error_image, + expand=False, + fill=False, + padding=0, + ) + message_hbox.pack_start( + self._message_widget_info_image, + expand=False, + fill=False, + padding=0, + ) + message_hbox.pack_start( + self._message_widget_label, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + message_hbox.pack_start( + vsep, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + message_hbox.pack_start( + self._console_launcher, expand=False, fill=False, padding=0 + ) + + def _update_error_widget(self): + # Update the error display widget. + self._error_widget_label.set_text(str(self.num_errors)) + self._error_widget.set_sensitive((self.num_errors > 0)) + + def _update_message_widget(self, message_text, kind): + # Update the message display widget. + if kind == metomi.rose.reporter.Reporter.KIND_ERR: + self._message_widget_error_image.show() + self._message_widget_info_image.hide() + else: + self._message_widget_error_image.hide() + self._message_widget_info_image.show() + last_line = message_text.splitlines()[-1] + self._message_widget_label.set_text(last_line) + + def _handle_enter_message_widget(self, *args): + tooltip_text = "" + for kind, message_text, message_time in self.messages[-5:]: + if kind == metomi.rose.reporter.Reporter.KIND_ERR: + prefix = metomi.rose.reporter.Reporter.PREFIX_FAIL + else: + prefix = metomi.rose.reporter.Reporter.PREFIX_INFO + suffix = datetime.datetime.fromtimestamp(message_time).strftime( + metomi.rose.config_editor.EVENT_TIME + ) + tooltip_text += prefix + " " + message_text + " " + suffix + "\n" + tooltip_text = tooltip_text.rstrip() + self._message_widget_label.set_tooltip_text(tooltip_text) + + def _get_console_messages(self): + err_category = ( + metomi.rose.config_editor.STATUS_BAR_CONSOLE_CATEGORY_ERROR + ) + info_category = ( + metomi.rose.config_editor.STATUS_BAR_CONSOLE_CATEGORY_INFO + ) + message_tuples = [] + for kind, message, time_info in self.messages: + if kind == metomi.rose.reporter.Reporter.KIND_ERR: + category = err_category + else: + category = info_category + message_tuples.append((category, message, time_info)) + return message_tuples + + def _handle_destroy_console(self): + self.console = None + + def _launch_console(self, *args): + if self.console is not None: + return self.console.present() + message_tuples = self._get_console_messages() + err_category = ( + metomi.rose.config_editor.STATUS_BAR_CONSOLE_CATEGORY_ERROR + ) + info_category = ( + metomi.rose.config_editor.STATUS_BAR_CONSOLE_CATEGORY_INFO + ) + window = self.get_toplevel() + self.console = metomi.rose.gtk.console.ConsoleWindow( + [err_category, info_category], + message_tuples, + [Gtk.STOCK_DIALOG_ERROR, Gtk.STOCK_DIALOG_INFO], + parent=window, + destroy_hook=self._handle_destroy_console, + ) + + def _update_console(self): + if self.console is not None: + self.console.update_messages(self._get_console_messages()) diff --git a/metomi/rose/config_editor/updater.py b/metomi/rose/config_editor/updater.py new file mode 100644 index 0000000000..a914ca2e39 --- /dev/null +++ b/metomi/rose/config_editor/updater.py @@ -0,0 +1,885 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import metomi.rose.config_editor + + +class Updater(object): + """This handles the updating of various statuses and displays.""" + + def __init__( + self, + data, + util, + reporter, + mainwindow, + main_handle, + nav_controller, + get_pagelist_func, + update_bar_widgets_func, + refresh_metadata_func, + is_pluggable=False, + ): + self.data = data + self.util = util + self.reporter = reporter + self.mainwindow = mainwindow + self.main_handle = main_handle + self.nav_controller = nav_controller + self.get_pagelist_func = get_pagelist_func + self.pagelist = [] # This is the current list of pages open. + self.load_errors = 0 + self.update_bar_widgets_func = update_bar_widgets_func + self.refresh_metadata_func = refresh_metadata_func + self.is_pluggable = is_pluggable + self.nav_panel = None # This may be set later. + + def namespace_data_is_modified(self, namespace): + """Return a string for namespace modifications or null string.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + if config_name is None: + return "" + config_data = self.data.config[config_name] + config_sections = config_data.sections + if config_name == namespace: + # This is the top-level. + if config_name not in self.data.saved_config_names: + return metomi.rose.config_editor.TREE_PANEL_TIP_ADDED_CONFIG + section_hashes = [] + for sect_data in list(config_sections.now.values()): + section_hashes.append(sect_data.to_hashable()) + old_section_hashes = [] + for sect_data in list(config_sections.save.values()): + old_section_hashes.append(sect_data.to_hashable()) + if set(section_hashes) ^ set(old_section_hashes): + return metomi.rose.config_editor.TREE_PANEL_TIP_CHANGED_CONFIG + allowed_sections = self.data.helper.get_sections_from_namespace( + namespace + ) + save_var_map = {} + for section in allowed_sections: + for var in config_data.vars.save.get(section, []): + if var.metadata["full_ns"] == namespace: + save_var_map.update({var.metadata["id"]: var}) + for var in config_data.vars.now.get(section, []): + if var.metadata["full_ns"] == namespace: + var_id = var.metadata["id"] + save_var = save_var_map.get(var_id) + if save_var is None: + return ( + metomi.rose.config_editor.TREE_PANEL_TIP_ADDED_VARS + ) + if save_var.to_hashable() != var.to_hashable(): + # Variable has changed in some form. + return ( + metomi.rose.config_editor.TREE_PANEL_TIP_CHANGED_VARS # noqa: E501 + ) + save_var_map.pop(var_id) + if save_var_map: + # Some variables are now absent. + return metomi.rose.config_editor.TREE_PANEL_TIP_REMOVED_VARS + if self.data.helper.get_ns_is_default(namespace): + sections = self.data.helper.get_sections_from_namespace(namespace) + for section in sections: + sect_data = config_sections.now.get(section) + save_sect_data = config_sections.save.get(section) + if (sect_data is None) != (save_sect_data is None): + return ( + metomi.rose.config_editor.TREE_PANEL_TIP_DIFF_SECTIONS + ) + if sect_data is not None and save_sect_data is not None: + if sect_data.to_hashable() != save_sect_data.to_hashable(): + return ( + metomi.rose.config_editor.TREE_PANEL_TIP_CHANGED_SECTIONS # noqa: E501 + ) + return "" + + def update_ns_tree_states(self, namespace): + """Refresh the tree panel states for a single row (namespace).""" + if self.nav_panel is not None: + latent_status = self.data.helper.get_ns_latent_status(namespace) + ignored_status = self.data.helper.get_ns_ignored_status(namespace) + ns_names = namespace.lstrip("/").split("/") + self.nav_panel.update_statuses( + ns_names, latent_status, ignored_status + ) + + def tree_trigger_update( + self, only_this_config=None, only_this_namespace=None + ): + """Reload the tree panel, and perform an update. + + If only_this_config is not None, perform an update only on the + particular configuration namespaces. + + If only_this_namespace is not None, perform a selective update + to save time. + + """ + if self.nav_panel is not None: + self.nav_panel.load_tree(None, self.nav_controller.namespace_tree) + if only_this_namespace is None: + self.update_all(only_this_config=only_this_config) + else: + self.update_all(skip_checking=True, skip_sub_data_update=True) + spaces = only_this_namespace.lstrip("/").split("/") + for i in range(len(spaces), 0, -1): + update_ns = "/" + "/".join(spaces[:i]) + self.update_namespace(update_ns, skip_sub_data_update=True) + self.update_ns_sub_data(only_this_namespace) + + def refresh_ids( + self, + config_name, + setting_ids, + is_loading=False, + are_errors_done=False, + skip_update=False, + ): + """Refresh and redraw settings if needed.""" + self.pagelist = self.get_pagelist_func() + nses_to_do = [] + for changed_id in setting_ids: + sect, opt = self.util.get_section_option_from_id(changed_id) + if opt is None: + name = self.data.helper.get_default_section_namespace( + sect, config_name + ) + if name in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(name) + page = self.pagelist[index] + page.refresh() + else: + var = self.data.helper.get_ns_variable(changed_id, config_name) + if var is None: + continue + name = var.metadata["full_ns"] + if name in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(name) + page = self.pagelist[index] + page.refresh(changed_id) + if name not in nses_to_do and not are_errors_done: + nses_to_do.append(name) + if not skip_update: + for name in nses_to_do: + self.update_namespace( + name, is_loading=is_loading, skip_sub_data_update=True + ) + self.update_ns_sub_data(nses_to_do) + + def update_all( + self, + only_this_config=None, + is_loading=False, + skip_checking=False, + skip_sub_data_update=False, + ): + """Loop over all namespaces and update.""" + unique_namespaces = self.data.helper.get_all_namespaces( + only_this_config + ) + + for name in unique_namespaces: + self.data.helper.clear_namespace_cached_statuses(name) + + if only_this_config is None: + configs = list(self.data.config.keys()) + else: + configs = [only_this_config] + for config_name in configs: + self.update_config(config_name) + self.pagelist = self.get_pagelist_func() + + if not skip_checking: + for name in unique_namespaces: + if name in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(name) + page = self.pagelist[index] + self.sync_page_var_lists(page) + self.update_ignored_statuses(name) + self.update_ns_tree_states(name) + self.perform_error_check(is_loading=is_loading) + + for name in unique_namespaces: + if name in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(name) + page = self.pagelist[index] + self.update_tree_status(page) # Faster. + else: + self.update_tree_status(name) + self.update_bar_widgets_func() + self.update_stack_viewer_if_open() + for config_name in configs: + self.update_metadata_id(config_name) + if not skip_sub_data_update: + self.update_ns_sub_data() + + def update_namespace( + self, + namespace, + are_errors_done=False, + is_loading=False, + skip_sub_data_update=False, + ): + """Update driver function. Updates the page if open.""" + self.pagelist = self.get_pagelist_func() + self.data.helper.clear_namespace_cached_statuses(namespace) + if namespace in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(namespace) + page = self.pagelist[index] + self.update_status( + page, + are_errors_done=are_errors_done, + skip_sub_data_update=skip_sub_data_update, + ) + else: + self.update_sections(namespace) + self.update_ignored_statuses(namespace) + if not are_errors_done and not is_loading: + self.perform_error_check(namespace) + self.update_tree_status(namespace) + if not is_loading: + self.update_bar_widgets_func() + self.update_stack_viewer_if_open() + self.update_ns_tree_states(namespace) + if namespace in list(self.data.config.keys()): + self.update_metadata_id(namespace) + if not skip_sub_data_update: + self.update_ns_sub_data(namespace) + + def update_status( + self, page, are_errors_done=False, skip_sub_data_update=False + ): + """Update ignored statuses and update the tree statuses.""" + self.pagelist = self.get_pagelist_func() + self.sync_page_var_lists(page) + self.update_sections(page.namespace) + self.update_ignored_statuses(page.namespace) + if not are_errors_done: + self.perform_error_check(page.namespace) + self.update_tree_status(page) + self.update_bar_widgets_func() + self.update_stack_viewer_if_open() + page.update_info() + self.update_ns_tree_states(page.namespace) + if page.namespace in list(self.data.config.keys()): + self.update_metadata_id(page.namespace) + if not skip_sub_data_update: + self.update_ns_sub_data(page.namespace) + + def update_ns_sub_data(self, namespaces=None): + """Update any relevant summary data on another page.""" + if not isinstance(namespaces, list): + namespaces = [namespaces] + for page in self.pagelist: + for namespace in namespaces: + if namespace is None or namespace.startswith(page.namespace): + break + else: + # No namespaces matched this page, skip + continue + page.sub_data = self.data.helper.get_sub_data_for_namespace( + page.namespace + ) + page.update_sub_data() + + def update_ns_info(self, namespace): + if namespace in [p.namespace for p in self.pagelist]: + index = [p.namespace for p in self.pagelist].index(namespace) + page = self.pagelist[index] + page.update_ignored() + page.update_info() + + def sync_page_var_lists(self, page): + """Make sure the list of page variables has the right members.""" + real, miss = self.data.helper.get_data_for_namespace(page.namespace) + page_real, page_miss = page.panel_data, page.ghost_data + refresh_vars = [] + action_vsets = [ + (page_real.remove, set(page_real) - set(real)), + (page_real.append, set(real) - set(page_real)), + (page_miss.remove, set(page_miss) - set(miss)), + (page_miss.append, set(miss) - set(page_miss)), + ] + + for action, v_set in action_vsets: + for var in v_set: + if var not in refresh_vars: + refresh_vars.append(var) + for var in v_set: + action(var) + for var in refresh_vars: + page.refresh(var.metadata["id"]) + + def update_config(self, namespace): + """Update the config object for the macros.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config = self.data.dump_to_internal_config(config_name) + self.data.config[config_name].config = config + + def update_sections(self, namespace): + """Update the list of sections that are empty.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + ns_sections = self.data.helper.get_sections_from_namespace(namespace) + for section in ns_sections: + sect_data = config_data.sections.now.get(section) + if sect_data is None: + continue + variables = config_data.vars.now.get(section, []) + sect_data.options = [] + if not variables: + if section in config_data.vars.now: + config_data.vars.now.pop(section) + for variable in variables: + var_id = variable.metadata["id"] + option = self.util.get_section_option_from_id(var_id)[1] + sect_data.options.append(option) + + def update_ignored_statuses(self, namespace): + """Refresh the list of ignored variables and update relevant pages.""" + config_name = self.util.split_full_ns(self.data, namespace)[0] + config_data = self.data.config[config_name] + # Check for triggering variables that have changed values + self.data.trigger_id_value_lookup.setdefault(config_name, {}) + trig_id_val_dict = self.data.trigger_id_value_lookup[config_name] + trigger = self.data.trigger[config_name] + updated_ids = [] + + this_ns_triggers = [] + ns_vars, ns_l_vars = self.data.helper.get_data_for_namespace(namespace) + for var in ns_vars + ns_l_vars: + var_id = var.metadata["id"] + if not trigger.check_is_id_trigger(var_id, config_data.meta): + continue + if var in ns_l_vars: + new_val = None + else: + new_val = var.value + old_val = trig_id_val_dict.get(var_id) + if old_val != new_val: # new_val or old_val can be None + this_ns_triggers.append(var_id) + updated_ids += self.update_ignoreds(config_name, var_id) + + if not this_ns_triggers: + # No reason to update anything. + return False + + var_id_map = {} + for var in config_data.vars.get_all(skip_latent=True): + var_id = var.metadata["id"] + var_id_map.update({var_id: var}) + + update_nses = [] + update_section_nses = [] + for setting_id in updated_ids: + sect, opt = self.util.get_section_option_from_id(setting_id) + if opt is None: + sect_vars = config_data.vars.now.get(sect, []) + name = self.data.helper.get_default_section_namespace( + sect, config_name + ) + if name not in update_section_nses: + update_section_nses.append(name) + else: + sect_vars = list(config_data.vars.now.get(sect, [])) + sect_vars += list(config_data.vars.latent.get(sect, [])) + for var in list(sect_vars): + if var.metadata["id"] != setting_id: + sect_vars.remove(var) + for var in sect_vars: + var_ns = var.metadata["full_ns"] + var_id = var.metadata["id"] + vsect = self.util.get_section_option_from_id(var_id)[0] + if var_ns not in update_nses: + update_nses.append(var_ns) + if vsect in updated_ids and var_ns not in update_section_nses: + update_section_nses.append(var_ns) + for page in self.pagelist: + if page.namespace in update_nses: + page.update_ignored() # Redraw affected widgets. + if page.namespace in update_section_nses: + page.update_info() + for name in update_nses: + if name != namespace: + # We don't need another update of namespace. + self.update_namespace(name) + for var_id in list(trig_id_val_dict.keys()) + updated_ids: + var = var_id_map.get(var_id) + if var is None: + if var_id in trig_id_val_dict: + trig_id_val_dict.pop(var_id) + else: + trig_id_val_dict.update({var_id: var.value}) + + def update_ignoreds(self, config_name, var_id): + """Update the variable ignored flags ('reasons').""" + config_data = self.data.config[config_name] + trigger = self.data.trigger[config_name] + + meta_config = config_data.meta + config_sections = config_data.sections + config_data_for_trigger = { + "sections": config_sections.now, + "variables": config_data.vars.now, + } + update_ids = trigger.update( + var_id, config_data_for_trigger, meta_config + ) + update_vars = [] + update_sections = [] + for setting_id in update_ids: + section, option = self.util.get_section_option_from_id(setting_id) + if option is None: + update_sections.append(section) + else: + for var in config_data.vars.now.get(section, []): + if var.metadata["id"] == setting_id: + update_vars.append(var) + break + else: + for var in config_data.vars.latent.get(section, []): + if var.metadata["id"] == setting_id: + update_vars.append(var) + break + triggered_ns_list = [] + this_id = var_id + for namespace, metadata in list( + self.data.namespace_meta_lookup.items() + ): + this_name = self.util.split_full_ns(self.data, namespace) + if this_name != config_name: + continue + for section in update_sections: + if section in metadata["sections"]: + triggered_ns_list.append(namespace) + + # Update the sections. + enabled_sections = [ + s + for s in update_sections + if s in trigger.enabled_dict and s not in trigger.ignored_dict + ] + for section in update_sections: + # Clear pre-existing errors. + sect_vars = config_data.vars.now.get( + section, [] + ) + config_data.vars.latent.get(section, []) + sect_data = config_sections.now.get(section) + if sect_data is None: + sect_data = config_sections.latent[section] + for attribute in metomi.rose.config_editor.WARNING_TYPES_IGNORE: + if attribute in sect_data.error: + sect_data.error.pop(attribute) + reason = sect_data.ignored_reason + if section in enabled_sections: + # Trigger-enabled sections + if metomi.rose.variable.IGNORED_BY_USER in reason: + # User-ignored but trigger-enabled + if ( + meta_config.get( + [section, metomi.rose.META_PROP_COMPULSORY] + ).value + == metomi.rose.META_PROP_VALUE_TRUE + ): + # Doc table: I_u -> E -> compulsory + sect_data.error.update( + { + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED: ( # noqa: E501 + metomi.rose.config_editor.WARNING_NOT_USER_IGNORABLE # noqa: E501 + ) + } + ) + elif metomi.rose.variable.IGNORED_BY_SYSTEM in reason: + # Normal trigger-enabled sections + reason.pop(metomi.rose.variable.IGNORED_BY_SYSTEM) + for var in sect_vars: + name = var.metadata["full_ns"] + if name not in triggered_ns_list: + triggered_ns_list.append(name) + var.ignored_reason.pop( + metomi.rose.variable.IGNORED_BY_SECTION, None + ) + elif section in trigger.ignored_dict: + # Trigger-ignored sections + parents = trigger.ignored_dict.get(section, {}) + if parents: + help_text = "; ".join(list(parents.values())) + else: + help_text = ( + metomi.rose.config_editor.IGNORED_STATUS_DEFAULT + ) + reason.update( + {metomi.rose.variable.IGNORED_BY_SYSTEM: help_text} + ) + for var in sect_vars: + name = var.metadata["full_ns"] + if name not in triggered_ns_list: + triggered_ns_list.append(name) + var.ignored_reason.update( + {metomi.rose.variable.IGNORED_BY_SECTION: help_text} + ) + # Update the variables. + for var in update_vars: + var_id = var.metadata.get("id") + name = var.metadata.get("full_ns") + if name not in triggered_ns_list: + triggered_ns_list.append(name) + if var_id == this_id: + continue + for attribute in metomi.rose.config_editor.WARNING_TYPES_IGNORE: + if attribute in var.error: + var.error.pop(attribute) + if ( + var_id in trigger.enabled_dict + and var_id not in trigger.ignored_dict + ): + # Trigger-enabled variables + if metomi.rose.variable.IGNORED_BY_USER in var.ignored_reason: + # User-ignored but trigger-enabled + # Doc table: I_u -> E + if ( + var.metadata.get(metomi.rose.META_PROP_COMPULSORY) + == metomi.rose.META_PROP_VALUE_TRUE + ): + # Doc table: I_u -> E -> compulsory + var.error.update( + { + metomi.rose.config_editor.WARNING_TYPE_USER_IGNORED: ( # noqa: E501 + metomi.rose.config_editor.WARNING_NOT_USER_IGNORABLE # noqa: E501 + ) + } + ) + elif ( + metomi.rose.variable.IGNORED_BY_SYSTEM + in var.ignored_reason + ): + # Normal trigger-enabled variables + var.ignored_reason.pop( + metomi.rose.variable.IGNORED_BY_SYSTEM + ) + elif var_id in trigger.ignored_dict: + # Trigger-ignored variables + parents = trigger.ignored_dict.get(var_id, {}) + if parents: + help_text = "; ".join(list(parents.values())) + else: + help_text = ( + metomi.rose.config_editor.IGNORED_STATUS_DEFAULT + ) + var.ignored_reason.update( + {metomi.rose.variable.IGNORED_BY_SYSTEM: help_text} + ) + for namespace in triggered_ns_list: + self.update_tree_status(namespace) + return update_ids + + def update_tree_status(self, page_or_ns, icon_bool=None, icon_type=None): + """Update the tree statuses.""" + if self.nav_panel is None: + return + if isinstance(page_or_ns, str): + namespace = page_or_ns + config_name = self.util.split_full_ns(self.data, namespace)[0] + errors = [] + ns_vars, ns_l_vars = self.data.helper.get_data_for_namespace( + namespace + ) + for var in ns_vars + ns_l_vars: + errors += list(var.error.items()) + else: + namespace = page_or_ns.namespace + config_name = self.util.split_full_ns(self.data, namespace)[0] + errors = page_or_ns.validate_errors() + # Add section errors. + config_data = self.data.config[config_name] + ns_sections = self.data.helper.get_sections_from_namespace(namespace) + for section in ns_sections: + if section in config_data.sections.now: + errors += list(config_data.sections.now[section].error.items()) + elif section in config_data.sections.latent: + errors += list( + config_data.sections.latent[section].error.items() + ) + + # Set icons. + name_tree = namespace.lstrip("/").split("/") + if icon_bool is None: + if icon_type == "changed" or icon_type is None: + change = self.namespace_data_is_modified(namespace) + self.nav_panel.update_change(name_tree, change) + self.nav_panel.set_row_icon( + name_tree, bool(change), ind_type="changed" + ) + if icon_type == "error" or icon_type is None: + self.nav_panel.set_row_icon( + name_tree, len(errors), ind_type="error" + ) + else: + self.nav_panel.set_row_icon( + name_tree, icon_bool, ind_type=icon_type + ) + + def update_stack_viewer_if_open(self): + """Update the information in the stack viewer, if open.""" + if self.is_pluggable: + return False + if isinstance( + self.mainwindow.log_window, + metomi.rose.config_editor.stack.StackViewer, + ): + self.mainwindow.log_window.update() + + def focus_sub_page_if_open(self, namespace, node_id): + """Focus the sub (summary) page for a namespace and id.""" + if "/" not in namespace: + return False + summary_namespace = namespace.rsplit("/", 1)[0] + self.pagelist = self.get_pagelist_func() + page_namespaces = [p.namespace for p in self.pagelist] + if summary_namespace not in page_namespaces: + return False + page = self.pagelist[page_namespaces.index(summary_namespace)] + page.set_sub_focus(node_id) + + def update_metadata_id(self, config_name): + """Update the metadata if the id has changed.""" + config_data = self.data.config[config_name] + new_meta_id = self.data.helper.get_config_meta_flag(config_name) + if config_data.meta_id != new_meta_id: + config_data.meta_id = new_meta_id + self.refresh_metadata_func(config_name=config_name) + + def perform_startup_check(self): + """Fix any relevant type errors.""" + for config_name in self.data.config: + macro_config = self.data.dump_to_internal_config(config_name) + meta_config = self.data.config[config_name].meta + # Duplicate checking + dupl_checker = metomi.rose.macros.duplicate.DuplicateChecker() + problem_list = dupl_checker.validate(macro_config, meta_config) + if problem_list: + self.main_handle.handle_macro_validation( + config_name, + "duplicate.DuplicateChecker.validate", + macro_config, + problem_list, + no_display=True, + ) + format_checker = metomi.rose.macros.format.FormatChecker() + problem_list = format_checker.validate(macro_config, meta_config) + if problem_list: + self.main_handle.handle_macro_validation( + config_name, + "format.FormatChecker.validate", + macro_config, + problem_list, + ) + + def perform_error_check(self, namespace=None, is_loading=False): + """Loop through system macros and sum errors.""" + configs = list(self.data.config.keys()) + if namespace is not None: + config_name = self.util.split_full_ns(self.data, namespace)[0] + configs = [config_name] + # Compulsory checking. + for config_name in configs: + config_data = self.data.config[config_name] + meta = config_data.meta + checker = self.data.builtin_macros[config_name][ + metomi.rose.META_PROP_COMPULSORY + ] + only_these_sections = None + if namespace is not None: + only_these_sections = ( + self.data.helper.get_sections_from_namespace(namespace) + ) + config_data_for_compulsory = { + "sections": config_data.sections.now, + "variables": config_data.vars.now, + } + bad_list = checker.validate_settings( + config_data_for_compulsory, + config_data.meta, + only_these_sections=only_these_sections, + ) + self.apply_macro_validation( + config_name, + metomi.rose.META_PROP_COMPULSORY, + bad_list, + namespace, + is_loading=is_loading, + is_macro_dynamic=True, + ) + # Value checking. + for config_name in configs: + config_data = self.data.config[config_name] + meta = config_data.meta + checker = self.data.builtin_macros[config_name][ + metomi.rose.META_PROP_TYPE + ] + if namespace is None: + real_variables = config_data.vars.get_all(skip_latent=True) + else: + real_variables = self.data.helper.get_data_for_namespace( + namespace + )[0] + bad_list = checker.validate_variables(real_variables, meta) + self.apply_macro_validation( + config_name, + metomi.rose.META_PROP_TYPE, + bad_list, + namespace, + is_loading=is_loading, + is_macro_dynamic=True, + ) + + def apply_macro_validation( + self, + config_name, + macro_type, + bad_list=None, + namespace=None, + is_loading=False, + is_macro_dynamic=False, + ): + """Display error icons if a variable is in the wrong state.""" + if bad_list is None: + bad_list = [] + config_data = self.data.config[config_name] + config_sections = config_data.sections + variables = config_data.vars.get_all() + id_error_dict = {} + id_warn_dict = {} + if namespace is None: + ok_sections = list(config_sections.now.keys()) + list( + config_sections.latent.keys() + ) + ok_variables = variables + else: + ok_sections = self.data.helper.get_sections_from_namespace( + namespace + ) + ok_variables = [ + v for v in variables if v.metadata.get("full_ns") == namespace + ] + for section in ok_sections: + sect_data = config_sections.now.get(section) + if sect_data is None: + sect_data = config_sections.latent.get(section) + if sect_data is None: + continue + if macro_type in sect_data.error: + this_error = sect_data.error.pop(macro_type) + id_error_dict.update({section: this_error}) + if macro_type in sect_data.warning: + this_warning = sect_data.warning.pop(macro_type) + id_warn_dict.update({section: this_warning}) + for var in ok_variables: + if macro_type in var.error: + this_error = var.error.pop(macro_type) + id_error_dict.update({var.metadata["id"]: this_error}) + if macro_type in var.warning: + this_warning = var.warning.pop(macro_type) + id_warn_dict.update({var.metadata["id"]: this_warning}) + if not bad_list: + self.refresh_ids( + config_name, + list(id_error_dict.keys()) + list(id_warn_dict.keys()), + is_loading, + are_errors_done=is_macro_dynamic, + ) + return + for bad_report in bad_list: + section = bad_report.section + key = bad_report.option + info = bad_report.info + if key is None: + setting_id = section + if ( + namespace is not None + and section + not in self.data.helper.get_sections_from_namespace( + namespace + ) + ): + continue + sect_data = config_sections.now.get(section) + if sect_data is None: + sect_data = config_sections.latent.get(section) + if sect_data is None: + continue + if bad_report.is_warning: + sect_data.warning.setdefault(macro_type, info) + else: + sect_data.error.setdefault(macro_type, info) + else: + setting_id = self.util.get_id_from_section_option(section, key) + var = self.data.helper.get_variable_by_id( + setting_id, config_name + ) + if var is None: + var = self.data.helper.get_variable_by_id( + setting_id, config_name, latent=True + ) + if var is None: + continue + if ( + namespace is not None + and var.metadata["full_ns"] != namespace + ): + continue + if bad_report.is_warning: + var.warning.setdefault(macro_type, info) + else: + var.error.setdefault(macro_type, info) + if bad_report.is_warning: + map_ = id_warn_dict + else: + map_ = id_error_dict + if is_loading: + self.load_errors += 1 + update_text = ( + metomi.rose.config_editor.EVENT_LOAD_ERRORS.format( + self.data.top_level_name, self.load_errors + ) + ) + + self.reporter.report_load_event( + update_text, no_progress=True + ) + if setting_id in map_: + # No need for further update, already had warning/error. + map_.pop(setting_id) + else: + # New warning or error. + map_.update({setting_id: info}) + self.refresh_ids( + config_name, + list(id_error_dict.keys()) + list(id_warn_dict.keys()), + is_loading, + are_errors_done=is_macro_dynamic, + ) + + def apply_macro_transform( + self, config_name, changed_ids, skip_update=False + ): + """Refresh pages with changes.""" + self.refresh_ids(config_name, changed_ids, skip_update=skip_update) diff --git a/metomi/rose/config_editor/upgrade_controller.py b/metomi/rose/config_editor/upgrade_controller.py new file mode 100644 index 0000000000..5137314feb --- /dev/null +++ b/metomi/rose/config_editor/upgrade_controller.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import copy +import os + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +from gi.repository import GObject + +import metomi.rose.gtk.util +import metomi.rose.macro +import metomi.rose.upgrade + +from metomi.rose.macros.trigger import TriggerMacro + + +class UpgradeController(object): + """Configure the upgrade of configurations.""" + + def __init__( + self, + app_config_dict, + handle_transform_func, + parent_window=None, + upgrade_inspector=None, + ): + buttons = ( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_APPLY, + Gtk.ResponseType.ACCEPT, + ) + self.window = Gtk.Dialog(buttons=buttons) + self.set_transient_for(parent_window) + self.window.set_title(metomi.rose.config_editor.DIALOG_TITLE_UPGRADE) + self.config_dict = {} + self.config_directory_dict = {} + self.config_manager_dict = {} + config_names = sorted(app_config_dict.keys()) + self._config_version_model_dict = {} + self.use_all_versions = False + self.treemodel = Gtk.TreeStore(str, str, str, bool) + self.treeview = metomi.rose.gtk.util.TooltipTreeView( + get_tooltip_func=self._get_tooltip + ) + self.treeview.show() + old_pwd = os.getcwd() + for config_name in config_names: + app_config = app_config_dict[config_name]["config"] + app_directory = app_config_dict[config_name]["directory"] + meta_value = app_config.get_value( + [ + metomi.rose.CONFIG_SECT_TOP, + metomi.rose.CONFIG_OPT_META_TYPE, + ], + "", + ) + if len(meta_value.split("/")) < 2: + continue + try: + os.chdir(app_directory) + manager = metomi.rose.upgrade.MacroUpgradeManager(app_config) + except OSError: + # This can occur when access is not allowed to metadata files. + continue + self.config_dict[config_name] = app_config + self.config_directory_dict[config_name] = app_directory + self.config_manager_dict[config_name] = manager + self._update_treemodel_data(config_name) + os.chdir(old_pwd) + self.treeview.set_model(self.treemodel) + self.treeview.set_rules_hint(True) + self.treewindow = Gtk.ScrolledWindow() + self.treewindow.show() + self.treewindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + columns = metomi.rose.config_editor.DIALOG_COLUMNS_UPGRADE + for i, title in enumerate(columns): + column = Gtk.TreeViewColumn() + column.set_title(title) + if self.treemodel.get_column_type(i) == GObject.TYPE_BOOLEAN: + cell = Gtk.CellRendererToggle() + cell.connect("toggled", self._handle_toggle_upgrade, i) + cell.set_property("activatable", True) + elif i == 2: + self._combo_cell = Gtk.CellRendererCombo() + self._combo_cell.set_property("has-entry", False) + self._combo_cell.set_property("editable", True) + self._combo_cell.connect( + "changed", self._handle_change_version, 2 + ) + cell = self._combo_cell + else: + cell = Gtk.CellRendererText() + if i == len(columns) - 1: + column.pack_start(cell, True, True, 0) + else: + column.pack_start(cell, False, True, 0) + column.set_cell_data_func(cell, self._set_cell_data, i) + self.treeview.append_column(column) + self.treeview.connect("cursor-changed", self._handle_change_cursor) + self.treewindow.add(self.treeview) + self.window.vbox.pack_start( + self.treewindow, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + label = Gtk.Label(label=metomi.rose.config_editor.DIALOG_LABEL_UPGRADE) + label.show() + self.window.vbox.pack_start( + label, True, True, metomi.rose.config_editor.SPACING_PAGE + ) + button_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + button_hbox.show() + all_versions_toggle_button = Gtk.CheckButton( + label=metomi.rose.config_editor.DIALOG_LABEL_UPGRADE_ALL, + use_underline=False, + ) + all_versions_toggle_button.set_active(self.use_all_versions) + all_versions_toggle_button.connect( + "toggled", self._handle_toggle_all_versions + ) + all_versions_toggle_button.show() + button_hbox.pack_start( + all_versions_toggle_button, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + self.window.vbox.pack_end( + button_hbox, expand=False, fill=False, padding=0 + ) + self.ok_button = self.window.action_area.get_children()[0] + self.window.set_focus(all_versions_toggle_button) + self.window.set_focus(self.ok_button) + self._set_ok_to_upgrade() + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + my_size = self.window.size_request() + new_size = [-1, -1] + extra = 2 * metomi.rose.config_editor.SPACING_PAGE + for i in [0, 1]: + new_size[i] = min([my_size[i] + extra, max_size[i]]) + self.treewindow.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + self.window.set_default_size(*new_size) + response = self.window.run() + old_pwd = os.getcwd() + if response == Gtk.ResponseType.ACCEPT: + iter_ = self.treemodel.get_iter_first() + while iter_ is not None: + config_name = self.treemodel.get_value(iter_, 0) + curr_version = self.treemodel.get_value(iter_, 1) + next_version = self.treemodel.get_value(iter_, 2) + ok_to_upgrade = self.treemodel.get_value(iter_, 3) + config = self.config_dict[config_name] + manager = self.config_manager_dict[config_name] + directory = self.config_directory_dict[config_name] + if not ok_to_upgrade or next_version == curr_version: + iter_ = self.treemodel.iter_next(iter_) + continue + os.chdir(directory) + manager.set_new_tag(next_version) + macro_config = copy.deepcopy(config) + try: + new_config, change_list = manager.transform( + macro_config, custom_inspector=upgrade_inspector + ) + except Exception as exc: + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + type(exc).__name__ + ": " + str(exc), + metomi.rose.config_editor.ERROR_UPGRADE.format( + config_name.lstrip("/") + ), + ) + iter_ = self.treemodel.iter_next(iter_) + continue + macro_id = ( + type(manager).__name__ + + "." + + metomi.rose.macro.TRANSFORM_METHOD + ) + if handle_transform_func( + config_name, + macro_id, + new_config, + change_list, + triggers_ok=True, + ): + meta_config = metomi.rose.macro.load_meta_config( + new_config, + config_type=metomi.rose.SUB_CONFIG_NAME, + ignore_meta_error=True, + ) + trig_macro = TriggerMacro() + macro_config = copy.deepcopy(new_config) + macro_id = ( + metomi.rose.upgrade.MACRO_UPGRADE_TRIGGER_NAME + + "." + + metomi.rose.macro.TRANSFORM_METHOD + ) + if not trig_macro.validate_dependencies( + macro_config, meta_config + ): + ( + new_trig_config, + trig_change_list, + ) = TriggerMacro().transform(macro_config, meta_config) + handle_transform_func( + config_name, + macro_id, + new_trig_config, + trig_change_list, + triggers_ok=True, + ) + iter_ = self.treemodel.iter_next(iter_) + os.chdir(old_pwd) + self.window.destroy() + + def _get_tooltip(self, view, row_iter, col_index, tip): + name = self.treeview.get_column(col_index).get_title() + value = str(self.treemodel.get_value(row_iter, col_index)) + tip.set_text(name + ": " + value) + return True + + def _handle_change_cursor(self, view): + path = self.treeview.get_cursor()[0] + iter_ = self.treemodel.get_iter(path) + config_name = self.treemodel.get_value(iter_, 0) + listmodel = self._config_version_model_dict[config_name] + self._combo_cell.set_property("model", listmodel) + self._combo_cell.set_property("text-column", 0) + + def _handle_change_version(self, cell, path, new, col_index): + iter_ = self.treemodel.get_iter(path) + if isinstance(new, str): + new_value = new + else: + new_value = cell.get_property("model").get_value(new, 0) + self.treemodel.set_value(iter_, col_index, new_value) + + def _handle_toggle_all_versions(self, button): + self.use_all_versions = button.get_active() + self.treemodel = Gtk.TreeStore(str, str, str, bool) + self._config_version_model_dict.clear() + for config_name in sorted(self.config_dict.keys()): + self._update_treemodel_data(config_name) + self.treeview.set_model(self.treemodel) + self._set_ok_to_upgrade() + + def _handle_toggle_upgrade(self, cell, path, col_index): + iter_ = self.treemodel.get_iter(path) + value = self.treemodel.get_value(iter_, col_index) + if self.treemodel.get_value(iter_, 1) == self.treemodel.get_value( + iter_, 2 + ): + self.treemodel.set_value(iter_, col_index, False) + else: + self.treemodel.set_value(iter_, col_index, not value) + self._set_ok_to_upgrade() + + def _set_ok_to_upgrade(self, *args): + any_upgrades_toggled = False + iter_ = self.treemodel.get_iter_first() + while iter_ is not None: + if self.treemodel.get_value(iter_, 3): + any_upgrades_toggled = True + break + iter_ = self.treemodel.iter_next(iter_) + self.ok_button.set_sensitive(any_upgrades_toggled) + + def _set_cell_data(self, column, cell, model, r_iter, col_index): + if model.get_column_type(col_index) == GObject.TYPE_BOOLEAN: + cell.set_property("active", model.get_value(r_iter, col_index)) + if model.get_value(r_iter, 1) == model.get_value(r_iter, 2): + model.set_value(r_iter, col_index, False) + cell.set_property("inconsistent", True) + cell.set_property("sensitive", False) + else: + cell.set_property("inconsistent", False) + cell.set_property("sensitive", True) + elif col_index == 2: + cell.set_property("text", model.get_value(r_iter, 2)) + else: + text = model.get_value(r_iter, col_index) + if col_index == 0: + text = text.lstrip("/") + cell.set_property("text", text) + + def _update_treemodel_data(self, config_name): + manager = self.config_manager_dict[config_name] + current_tag = manager.tag + next_tag = manager.get_new_tag(only_named=not self.use_all_versions) + if next_tag is None: + self.treemodel.append( + None, [config_name, current_tag, current_tag, False] + ) + else: + self.treemodel.append( + None, [config_name, current_tag, next_tag, True] + ) + listmodel = Gtk.ListStore(str) + tags = manager.get_tags(only_named=not self.use_all_versions) + if not tags: + tags = [manager.tag] + for tag in tags: + listmodel.append([tag]) + self._config_version_model_dict[config_name] = listmodel diff --git a/metomi/rose/config_editor/util.py b/metomi/rose/config_editor/util.py new file mode 100644 index 0000000000..292367601e --- /dev/null +++ b/metomi/rose/config_editor/util.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +""" +This module contains a utility class to transform between data types. + +It also contains a function to launch an introspective dialog, and +one to import custom plugins. + +""" + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util + + +class Lookup(object): + """Collection of data lookup functions used by multiple modules.""" + + def __init__(self): + self.section_option_id_lookup = {} + self.full_ns_split_lookup = {} + + def get_id_from_section_option(self, section, option): + """Return a variable id from a section and option.""" + if option is None: + id_ = str(section) + else: + id_ = section + metomi.rose.CONFIG_DELIMITER + option + self.section_option_id_lookup[id_] = (section, option) + return id_ + + def get_section_option_from_id(self, var_id): + """Return a section and option from a variable id. + + This uses a dictionary to improve speed, as this method + is called repeatedly with no variation in results. + + """ + if var_id in self.section_option_id_lookup: + return self.section_option_id_lookup[var_id] + split_char = metomi.rose.CONFIG_DELIMITER + option_name = var_id.split(split_char)[-1] + section = var_id.replace(split_char + option_name, "", 1) + if option_name == section: + option_name = None + self.section_option_id_lookup[var_id] = (section, option_name) + return section, option_name + + def split_full_ns(self, data, full_namespace): + """Return the config name and the internal namespace from full ns.""" + if full_namespace not in self.full_ns_split_lookup: + for config_name in list(data.config.keys()): + if config_name == "/" + data.top_level_name: + continue + if full_namespace.startswith(config_name + "/"): + sub_space = full_namespace.replace( + config_name + "/", "", 1 + ) + self.full_ns_split_lookup[full_namespace] = ( + config_name, + sub_space, + ) + break + elif full_namespace == config_name: + sub_space = "" + self.full_ns_split_lookup[full_namespace] = ( + config_name, + sub_space, + ) + break + else: + # A top level based namespace + config_name = "/" + data.top_level_name + sub_space = full_namespace.replace(config_name + "/", "", 1) + self.full_ns_split_lookup[full_namespace] = ( + config_name, + sub_space, + ) + return self.full_ns_split_lookup.get(full_namespace, (None, None)) + + +class ImportWidgetError(Exception): + """An exception raised when an imported widget cannot be used.""" + + def __str__(self): + return self.args[0] + + +def launch_node_info_dialog(node, changes, search_function): + """Launch a dialog displaying attributes of a variable or section.""" + title = node.__class__.__name__ + " " + node.metadata["id"] + text = "" + if changes: + text += ( + metomi.rose.config_editor.DIALOG_NODE_INFO_CHANGES.format(changes) + + "\n" + ) + text += metomi.rose.config_editor.DIALOG_NODE_INFO_DATA + try: + att_list = list(vars(node).items()) + except TypeError: + # vars will fail when __slots__ are used. + att_list = node.getattrs() + att_list.sort() + att_list.sort(key=lambda x: (x[0] in ["name", "value"])) + metadata_start_index = len(att_list) + for key, value in sorted(node.metadata.items()): + att_list.append([key, value]) + delim = metomi.rose.config_editor.DIALOG_NODE_INFO_DELIMITER + name = metomi.rose.config_editor.DIALOG_NODE_INFO_ATTRIBUTE + maxlen = metomi.rose.config_editor.DIALOG_NODE_INFO_MAX_LEN + for i, (att_name, att_val) in enumerate(att_list): + if ( + att_name == "metadata" + or att_name.startswith("_") + or callable(att_val) + or att_name == "old_value" + ): + continue + if i == metadata_start_index: + text += "\n" + metomi.rose.config_editor.DIALOG_NODE_INFO_METADATA + prefix = name.format(att_name) + delim + indent0 = len(prefix) + text += prefix + lenval = maxlen - indent0 + text += _pretty_format_data( + att_val, global_indent=indent0, width=lenval + ) + text += "\n" + metomi.rose.gtk.dialog.run_hyperlink_dialog( + Gtk.STOCK_DIALOG_INFO, text, title, search_function + ) + + +def launch_error_dialog(exception=None, text=""): + """This will be replaced by metomi.rose.reporter utilities.""" + if text: + text += "\n" + if exception is not None: + text += type(exception).__name__ + ": " + str(exception) + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, + text, + metomi.rose.config_editor.DIALOG_TITLE_ERROR, + modal=False, + ) + + +def text_for_character_widget(text): + """Strip an enclosing single quote pair from a piece of text.""" + if text.startswith("'") and text.endswith("'"): + text = text[1:-1] + text = text.replace("''", "'") + return text + + +def text_from_character_widget(text): + """Surround text with single quotes; escape existing ones.""" + return "'" + text.replace("'", "''") + "'" + + +def text_for_quoted_widget(text): + """Strip an enclosing double quote pair from a piece of text.""" + if text.startswith('"') and text.endswith('"'): + text = text[1:-1] + text = text.replace('\\"', '"') + return text + + +def text_from_quoted_widget(text): + """Surround text with double quotes; escape existing ones.""" + return '"' + text.replace('"', '\\"') + '"' + + +def wrap_string(text, maxlen=72, indent0=0, maxlines=4, sep=","): + """Return a wrapped string - 'textwrap' is not flexible enough for this.""" + lines = [""] + linelen = maxlen - indent0 + for item in text.split(sep): + dtext = metomi.rose.gtk.util.safe_str(item) + sep + if lines[-1] and len(lines[-1] + dtext) > linelen: + lines.append("") + linelen = maxlen + lines[-1] += dtext + lines[-1] = lines[-1][: -len(sep)] + if len(lines) > maxlines: + lines = lines[:4] + ["..."] + return "\n".join(lines) + + +def null_cmp(x_item, y_item): + """Compares sort_key and then id of the tuples x_item/y_item.""" + x_sort_key, x_id = x_item[0:2] + y_sort_key, y_id = y_item[0:2] + if x_id == "" or y_id == "": + return (x_id == "") - (y_id == "") + if x_sort_key == y_sort_key: + return metomi.rose.config.sort_settings(x_id, y_id) + return (x_sort_key > y_sort_key) - (x_sort_key < y_sort_key) + + +def _pretty_format_data(data, global_indent=0, indent=4, width=60): + sub_name = metomi.rose.config_editor.DIALOG_NODE_INFO_SUB_ATTRIBUTE + safe_str = metomi.rose.gtk.util.safe_str + delim = metomi.rose.config_editor.DIALOG_NODE_INFO_DELIMITER + if isinstance(data, dict) and data: + text = "" + for key, val in list(data.items()): + text += "\n" + " " * global_indent + sub_prefix = sub_name.format(safe_str(key)) + delim + indent_next = global_indent + indent + str_val = _pretty_format_data(val, global_indent=indent_next) + text += sub_prefix + str_val + return text + if isinstance(data, list) and data: + text = ",".join([_pretty_format_data(v) for v in data]) + return wrap_string(text, width, global_indent) + if data != {} and data != []: + return wrap_string(str(data), width, global_indent) + return "" diff --git a/metomi/rose/config_editor/valuewidget/__init__.py b/metomi/rose/config_editor/valuewidget/__init__.py new file mode 100644 index 0000000000..f276cc66b5 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/__init__.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose +from . import array +from . import booltoggle +from . import character +from . import combobox +from . import intspin +from . import meta +from . import radiobuttons +from . import text +from . import valuehints + + +NON_TEXT_TYPES = ( + "boolean", + "integer", + "logical", + "python_boolean", + "python_list", + "real", + "spaced_list", +) + + +class ValueWidgetHook(object): + """Provides hook functions for valuewidgets.""" + + def __init__(self, scroll_func=None, focus_func=None): + """Set up commonly used valuewidget hook functions.""" + self._scroll_func = scroll_func + self._focus_func = focus_func + + def trigger_scroll(self, widget, event): + """Set up top-level scrolling on a widget event.""" + if self._scroll_func is None: + return False + return self._scroll_func(widget, event) + + def get_focus(self, widget): + """Set up a trigger based on focusing for a widget.""" + if self._focus_func is None: + if isinstance(widget, Gtk.Entry): + Gtk.Widget.grab_focus(widget) + return + Gtk.Widget.grab_focus(widget) + return + return self._focus_func(widget) + + def copy(self): + """Return a copy of this instance.""" + return ValueWidgetHook(self.trigger_scroll, self.get_focus) + + +def chooser(value, metadata, error): + """Select an appropriate widget class based on the arguments. + + Note: rose edit overrides this logic if a widget is hard coded. + + """ + m_type = metadata.get(metomi.rose.META_PROP_TYPE) + m_values = metadata.get(metomi.rose.META_PROP_VALUES) + m_length = metadata.get(metomi.rose.META_PROP_LENGTH) + m_hint = metadata.get(metomi.rose.META_PROP_VALUE_HINTS) + contains_env = metomi.rose.env.contains_env_var(value) + is_list = m_length is not None or isinstance(m_type, list) + + # determine widget by presence of environment variables + if contains_env and (not m_type or m_type in NON_TEXT_TYPES or is_list): + # it is not safe to display the widget as intended due to an env var + if "\n" in value: + return text.TextMultilineValueWidget + else: + return text.RawValueWidget + + # determine widget by metadata length + if is_list: + if isinstance(m_type, list): + # irregular array + return array.mixed.MixedArrayValueWidget + elif m_type in ["logical", "boolean", "python_boolean"]: + # regular array (boolean) + return array.logical.LogicalArrayValueWidget + else: + # regular array (generic) + return array.entry.EntryArrayValueWidget + + # determine widget by metadata values + if m_values is not None: + if len(m_values) <= 4: + # short list + return radiobuttons.RadioButtonsValueWidget + else: + # long list + return combobox.ComboBoxValueWidget + + # determine widget by metadata type + if m_type == "integer": + return intspin.IntSpinButtonValueWidget + if m_type == "meta": + return meta.MetaValueWidget + if m_type == "str_multi": + return text.TextMultilineValueWidget + if m_type in ["character", "quoted"]: + return character.QuotedTextValueWidget + if m_type == "python_list" and not error: + return array.python_list.PythonListValueWidget + if m_type == "spaced_list" and not error: + return array.spaced_list.SpacedListValueWidget + if m_type in ["logical", "boolean", "python_boolean"]: + return booltoggle.BoolToggleValueWidget + + # determine widget by metadata hint + if m_hint is not None: + return valuehints.HintsValueWidget + + # fall back to a text widget + if "\n" in value: + return text.TextMultilineValueWidget + else: + return text.RawValueWidget diff --git a/metomi/rose/config_editor/valuewidget/array/__init__.py b/metomi/rose/config_editor/valuewidget/array/__init__.py new file mode 100644 index 0000000000..31b1db0df2 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +# flake8: noqa: F401 + +from . import python_list +from . import logical +from . import mixed +from . import spaced_list diff --git a/metomi/rose/config_editor/valuewidget/array/entry.py b/metomi/rose/config_editor/valuewidget/array/entry.py new file mode 100644 index 0000000000..78ffd97d77 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/entry.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor.util +import metomi.rose.gtk.util +import metomi.rose.variable + + +class EntryArrayValueWidget(Gtk.Box): + """This is a class to represent multiple array entries.""" + + TIP_ADD = "Add array element" + TIP_DEL = "Remove array element" + TIP_ELEMENT = "Element {0}" + TIP_ELEMENT_CHAR = "Element {0}: '{1}'" + TIP_LEFT = "Move array element left" + TIP_RIGHT = "Move array element right" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(EntryArrayValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.max_length = self.metadata[metomi.rose.META_PROP_LENGTH] + + value_array = metomi.rose.variable.array_split(self.value) + self.chars_width = max([len(v) for v in value_array] + [1]) + 1 + self.last_selected_src = None + arr_type = self.metadata.get(metomi.rose.META_PROP_TYPE) + self.is_char_array = arr_type == "character" + self.is_quoted_array = arr_type == "quoted" + # Do not treat character or quoted arrays specially when incorrect. + if self.is_char_array: + checker = metomi.rose.macros.value.ValueChecker() + for val in value_array: + if not checker.check_character(val): + self.is_char_array = False + if self.is_quoted_array: + checker = metomi.rose.macros.value.ValueChecker() + for val in value_array: + if not checker.check_quoted(val): + self.is_quoted_array = False + if self.is_char_array: + for i, val in enumerate(value_array): + value_array[i] = ( + metomi.rose.config_editor.util.text_for_character_widget( + val + ) + ) + if self.is_quoted_array: + for i, val in enumerate(value_array): + value_array[i] = ( + metomi.rose.config_editor.util.text_for_quoted_widget(val) + ) + # Designate the number of allowed columns - 10 for 4 chars width + self.num_allowed_columns = 3 + self.entry_table = Gtk.Table( + rows=1, columns=self.num_allowed_columns, homogeneous=True + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + + self.entries = [] + + self.has_titles = False + if "element-titles" in metadata: + self.has_titles = True + + self.generate_entries(value_array) + self.generate_buttons() + self.populate_table() + self.pack_start( + self.add_del_button_box, expand=False, fill=False, padding=0 + ) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.entry_table.connect_after( + "size-allocate", lambda w, e: self.reshape_table() + ) + self.connect( + "focus-in-event", + lambda w, e: self.hook.get_focus(self.get_focus_entry()), + ) + + def force_scroll(self, widget=None): + """Adjusts a scrolled window to display the correct widget.""" + y_coordinate = None + if widget is not None: + y_coordinate = widget.get_allocation().y + scroll_container = widget.get_parent() + if scroll_container is None: + return False + while not isinstance(scroll_container, Gtk.ScrolledWindow): + scroll_container = scroll_container.get_parent() + vadj = scroll_container.get_vadjustment() + if y_coordinate == -1: # Bad allocation, don't scroll + return False + if y_coordinate is None: + vadj.set_upper(vadj.get_upper() + 0.08 * vadj.get_page_size()) + vadj.set_value(vadj.get_upper() - vadj.get_page_size()) + return False + vadj.set_value(y_coordinate) + return False + + def get_focus_entry(self): + """Get either the last selected entry or the last one.""" + if self.last_selected_src is not None: + print("last selected ------------------------") + return self.last_selected_src + if len(self.entries) > 0: + print("last entry ------------------------") + return self.entries[-1] + print("none ------------------------") + return None + + def get_focus_index(self): + """Get the focus and position within the table of entries.""" + text = "" + for entry in self.entries: + val = entry.get_text() + if self.is_char_array: + val = ( + metomi.rose.config_editor.util.text_from_character_widget( + val + ) + ) + elif self.is_quoted_array: + val = metomi.rose.config_editor.util.text_from_quoted_widget( + val + ) + prefix = get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return None + if entry == self.entry_table.get_focus_child(): + return len(text + prefix) + entry.get_position() + text += prefix + val + return None + + def set_focus_index(self, focus_index=None): + """Set the focus and position within the table of entries.""" + if focus_index is None: + return + value_array = metomi.rose.variable.array_split(self.value) + text = "" + for i, val in enumerate(value_array): + prefix = get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if ( + len(text + prefix + val) >= focus_index + or i == len(value_array) - 1 + ): + if len(self.entries) > i: + self.entries[i].grab_focus() + val_offset = focus_index - len(text + prefix) + if self.is_char_array or self.is_quoted_array: + val_offset = max([0, val_offset - 1]) + self.entries[i].set_position(val_offset) + return + text += prefix + val + + def generate_entries(self, value_array=None): + """Create the Gtk.Entry objects for elements in the array.""" + if value_array is None: + value_array = metomi.rose.variable.array_split(self.value) + entries = [] + for value_item in value_array: + for entry in self.entries: + if entry.get_text() == value_item and entry not in entries: + entries.append(entry) + break + else: + entries.append(self.get_entry(value_item)) + self.entries = entries + + def generate_buttons(self): + """Create the left-right movement arrows and add button.""" + left_arrow = Gtk.ToolButton() + left_arrow.set_icon_name("pan-start-symbolic") + left_arrow.show() + left_arrow.connect("clicked", lambda x: self.move_element(-1)) + left_event_box = Gtk.EventBox() + left_event_box.add(left_arrow) + left_event_box.show() + left_event_box.set_tooltip_text(self.TIP_LEFT) + right_arrow = Gtk.ToolButton() + right_arrow.set_icon_name("pan-end-symbolic") + right_arrow.show() + right_arrow.connect("clicked", lambda x: self.move_element(1)) + right_event_box = Gtk.EventBox() + right_event_box.add(right_arrow) + right_event_box.show() + right_event_box.set_tooltip_text(self.TIP_RIGHT) + self.arrow_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.arrow_box.show() + self.arrow_box.pack_start( + left_event_box, expand=False, fill=False, padding=0 + ) + self.arrow_box.pack_end( + right_event_box, expand=False, fill=False, padding=0 + ) + self.set_arrow_sensitive(False, False) + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_DEL) + self.del_button.add(del_image) + self.del_button.show() + self.del_button.connect( + "button-release-event", lambda b, e: self.remove_entry() + ) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.button_box.show() + self.button_box.pack_start( + self.arrow_box, expand=False, fill=True, padding=0 + ) + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.show() + self.add_button.connect( + "button-release-event", lambda b, e: self.add_entry() + ) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.add_del_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add_del_button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.show() + + def set_arrow_sensitive(self, is_left_sensitive, is_right_sensitive): + """Control the sensitivity of the movement buttons.""" + sens_tuple = (is_left_sensitive, is_right_sensitive) + for i, event_box in enumerate(self.arrow_box.get_children()): + event_box.get_child().set_sensitive(sens_tuple[i]) + if not sens_tuple[i]: + event_box.set_state(Gtk.StateType.NORMAL) + + def move_element(self, num_places_right): + """Move the entry left or right.""" + entry = self.last_selected_src + if entry is None: + return + old_index = self.entries.index(entry) + if ( + old_index + num_places_right < 0 + or old_index + num_places_right > len(self.entries) - 1 + ): + return + self.entries.remove(entry) + self.entries.insert(old_index + num_places_right, entry) + self.populate_table() + self.setter(entry) + + def get_entry(self, value_item): + """Create a gtk Entry for this array element.""" + entry = Gtk.Entry() + entry.set_text(value_item) + entry.connect("focus-in-event", self._handle_focus_on_entry) + entry.connect("button-release-event", self._handle_middle_click_paste) + entry.connect_after("paste-clipboard", self.setter) + entry.connect_after("key-release-event", lambda e, v: self.setter(e)) + entry.connect_after( + "button-release-event", lambda e, v: self.setter(e) + ) + entry.connect("focus-out-event", self._handle_focus_off_entry) + entry.set_width_chars(self.chars_width - 1) + entry.show() + return entry + + def populate_table(self, focus_widget=None): + """Populate a table with the array elements, dynamically.""" + position = None + table_widgets = self.entries + [self.button_box] + table_children = self.entry_table.get_children() + if focus_widget is None: + for child in table_children: + if child.is_focus() and isinstance(child, Gtk.Entry): + focus_widget = child + position = focus_widget.get_position() + else: + position = focus_widget.get_position() + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + if ( + focus_widget is None + and self.entry_table.is_focus() + and len(self.entries) > 0 + ): + focus_widget = self.entries[-1] + position = len(focus_widget.get_text()) + num_fields = len(self.entries + [self.button_box]) + num_rows_now = 1 + (num_fields - 1) / self.num_allowed_columns + self.entry_table.resize(num_rows_now, self.num_allowed_columns) + if self.max_length.isdigit() and len(self.entries) >= int( + self.max_length + ): + self.add_button.hide() + else: + self.add_button.show() + if self.max_length.isdigit() and len(self.entries) <= int( + self.max_length + ): + self.del_button.hide() + elif len(self.entries) == 0: + self.del_button.hide() + else: + self.del_button.show() + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + index = self.entries.index(self.last_selected_src) + if index == 0: + self.set_arrow_sensitive(False, True) + elif index == len(self.entries) - 1: + self.set_arrow_sensitive(True, False) + if len(self.entries) < 2: + self.set_arrow_sensitive(False, False) + + if self.has_titles: + for col, label in enumerate(self.metadata["element-titles"]): + if col >= len(table_widgets) - 1: + break + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label = Gtk.Label(label=self.metadata["element-titles"][col]) + label.show() + widget.pack_start(label, expand=True, fill=True) + widget.show() + self.entry_table.attach( + widget, + col, + col + 1, + 0, + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + + for i, widget in enumerate(table_widgets): + if isinstance(widget, Gtk.Entry): + if self.is_char_array or self.is_quoted_array: + w_value = widget.get_text() + widget.set_tooltip_text( + self.TIP_ELEMENT_CHAR.format((i + 1), w_value) + ) + else: + widget.set_tooltip_text(self.TIP_ELEMENT.format((i + 1))) + row = i // self.num_allowed_columns + if self.has_titles: + row += 1 + column = i % self.num_allowed_columns + self.entry_table.attach( + widget, + column, + column + 1, + row, + row + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + if focus_widget is not None: + focus_widget.grab_focus() + focus_widget.set_position(position) + focus_widget.select_region(position, -1) + self.grab_focus = lambda: self.hook.get_focus( + self._get_widget_for_focus() + ) + self.check_resize() + + def reshape_table(self): + """Reshape a table according to the space allocated.""" + total_x_bound = self.entry_table.get_allocation().width + if not len(self.entries): + return False + entries_bound = sum([e.get_allocation().width for e in self.entries]) + each_entry_bound = entries_bound / len(self.entries) + maximum_entry_number = float(total_x_bound) / float(each_entry_bound) + rounded_max = int(maximum_entry_number) + 1 + if rounded_max != self.num_allowed_columns + 2 and rounded_max > 2: + self.num_allowed_columns = max(1, rounded_max - 2) + self.populate_table() + + def add_entry(self): + """Add a new entry (with null text) to the variable array.""" + entry = self.get_entry("") + entry.connect("focus-in-event", lambda w, e: self.force_scroll(w)) + self.entries.append(entry) + self._adjust_entry_length() + self.last_selected_src = entry + self.populate_table(focus_widget=entry) + if ( + self.metadata.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + ): + self.setter(entry) + + def remove_entry(self): + """Remove the last selected or the last entry.""" + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + entry = self.entries.pop( + self.entries.index(self.last_selected_src) + ) + self.last_selected_src = None + else: + entry = self.entries.pop() + self.populate_table() + self.setter(entry) + + def setter(self, widget): + """Reconstruct the new variable value from the entry array.""" + val_array = [] + # Prevent str without "" breaking the underlying Python syntax + for e in self.entries: + v = e.get_text() + if v in ("False", "True"): # Boolean + val_array.append(v) + elif (len(v) == 0) or (v[:1].isdigit()): # Empty or numeric + val_array.append(v) + elif not v.startswith('"'): # Str - add in leading and trailing " + val_array.append('"' + v + '"') + e.set_text('"' + v + '"') + e.set_position(len(v) + 1) + elif (not v.endswith('"')) or ( + len(v) == 1 + ): # Str - add in trailing " + val_array.append(v + '"') + e.set_text(v + '"') + e.set_position(len(v)) + else: + val_array.append(v) + max_length = max([len(v) for v in val_array] + [1]) + if max_length + 1 != self.chars_width: + self.chars_width = max_length + 1 + self._adjust_entry_length() + if widget is not None and not widget.is_focus(): + widget.grab_focus() + widget.set_position(len(widget.get_text())) + widget.select_region( + widget.get_position(), widget.get_position() + ) + if self.is_char_array: + for i, val in enumerate(val_array): + val_array[i] = ( + metomi.rose.config_editor.util.text_from_character_widget( + val + ) + ) + elif self.is_quoted_array: + for i, val in enumerate(val_array): + val_array[i] = ( + metomi.rose.config_editor.util.text_from_quoted_widget(val) + ) + entries_have_commas = any("," in v for v in val_array) + new_value = metomi.rose.variable.array_join(val_array) + if new_value != self.value: + self.value = new_value + self.set_value(new_value) + if entries_have_commas and not ( + self.is_char_array or self.is_quoted_array + ): + new_val_array = metomi.rose.variable.array_split(new_value) + if len(new_val_array) != len(self.entries): + self.generate_entries() + focus_index = None + for i, val in enumerate(val_array): + if "," in val: + val_post_comma = val[: val.index(",") + 1] + focus_index = len( + metomi.rose.variable.array_join( + new_val_array[:i] + [val_post_comma] + ) + ) + self.populate_table() + self.set_focus_index(focus_index) + return False + + def _adjust_entry_length(self): + for entry in self.entries: + entry.set_width_chars(self.chars_width) + entry.set_max_length(self.chars_width) + self.reshape_table() + + def _get_widget_for_focus(self): + if self.entries: + return self.entries[-1] + return self.entry_table + + def _handle_focus_off_entry(self, widget, event): + if widget == self.last_selected_src: + try: + widget.set_progress_fraction(1.0) + except AttributeError: + widget.drag_highlight() + if widget.get_position() is None: + widget.set_position(len(widget.get_text())) + + def _handle_focus_on_entry(self, widget, event): + if self.last_selected_src is not None: + try: + self.last_selected_src.set_progress_fraction(0.0) + except AttributeError: + self.last_selected_src.drag_unhighlight() + self.last_selected_src = widget + is_start = widget in self.entries and self.entries[0] == widget + is_end = widget in self.entries and self.entries[-1] == widget + self.set_arrow_sensitive(not is_start, not is_end) + if widget.get_text() != "": + widget.select_region(widget.get_position(), widget.get_position()) + return False + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self.setter(widget) + return False + + +def get_next_delimiter(array_text, next_element): + """Return the part of array_text immediately preceding next_element.""" + try: + val = array_text.index(next_element) + except ValueError: + # Substring not found. + return + if val == 0 and len(array_text) > 1: # Null or whitespace element. + while array_text[val].isspace(): + val += 1 + if array_text[val] == ",": + val += 1 + return array_text[:val] diff --git a/metomi/rose/config_editor/valuewidget/array/logical.py b/metomi/rose/config_editor/valuewidget/array/logical.py new file mode 100644 index 0000000000..a348a79e8c --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/logical.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.gtk.util +import metomi.rose.variable + + +class LogicalArrayValueWidget(Gtk.Box): + """This is a class to represent an array of logical or boolean types.""" + + TIP_ADD = "Add array element" + TIP_DEL = "Delete array element" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(LogicalArrayValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.max_length = metadata[metomi.rose.META_PROP_LENGTH] + value_array = metomi.rose.variable.array_split(value) + if metadata.get(metomi.rose.META_PROP_TYPE) == "boolean": + # boolean -> true/false + self.allowed_values = [ + metomi.rose.TYPE_BOOLEAN_VALUE_FALSE, + metomi.rose.TYPE_BOOLEAN_VALUE_TRUE, + ] + self.label_dict = dict( + list(zip(self.allowed_values, self.allowed_values)) + ) + elif metadata.get(metomi.rose.META_PROP_TYPE) == "python_boolean": + # python_boolean -> True/False + self.allowed_values = [ + metomi.rose.TYPE_PYTHON_BOOLEAN_VALUE_FALSE, + metomi.rose.TYPE_PYTHON_BOOLEAN_VALUE_TRUE, + ] + self.label_dict = dict( + list(zip(self.allowed_values, self.allowed_values)) + ) + else: + # logical -> .true./.false. + self.allowed_values = [ + metomi.rose.TYPE_LOGICAL_VALUE_FALSE, + metomi.rose.TYPE_LOGICAL_VALUE_TRUE, + ] + self.label_dict = { + metomi.rose.TYPE_LOGICAL_VALUE_FALSE: ( + metomi.rose.TYPE_LOGICAL_FALSE_TITLE + ), + metomi.rose.TYPE_LOGICAL_VALUE_TRUE: ( + metomi.rose.TYPE_LOGICAL_TRUE_TITLE + ), + } + + imgs = [ + (Gtk.STOCK_MEDIA_STOP, Gtk.IconSize.MENU), + (Gtk.STOCK_APPLY, Gtk.IconSize.MENU), + ] + self.make_log_image = lambda i: Gtk.Image.new_from_stock(*imgs[i]) + self.chars_width = max([len(v) for v in value_array] + [1]) + 1 + self.num_allowed_columns = 3 + self.entry_table = Gtk.Table( + rows=1, columns=self.num_allowed_columns, homogeneous=True + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + + self.entries = [] + for value_item in value_array: + entry = self.get_entry(value_item) + self.entries.append(entry) + + self.has_titles = False + if "element-titles" in metadata: + self.has_titles = True + + self.generate_buttons() + self.populate_table() + self.pack_start(self.button_box, expand=False, fill=False, padding=0) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.entry_table.connect_after( + "size-allocate", lambda w, e: self.reshape_table() + ) + self.connect( + "focus-in-event", + lambda w, e: self.hook.get_focus(self.get_focus_entry()), + ) + + def get_focus_entry(self): + """Get either the last selected button or the last one.""" + return self.entries[-1] + + def generate_buttons(self): + """Create the add button.""" + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.connect( + "button-release-event", lambda b, e: self.add_entry() + ) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_ADD) + self.del_button.add(del_image) + self.del_button.show() + self.del_button.connect("button-release-event", self.remove_entry) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.button_box.show() + self.button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + + def get_entry(self, value_item): + """Create a widget for this array element.""" + bad_img = Gtk.Image.new_from_stock( + Gtk.STOCK_DIALOG_WARNING, Gtk.IconSize.MENU + ) + button = Gtk.ToggleButton() + button.options = [ + metomi.rose.TYPE_LOGICAL_VALUE_FALSE, + metomi.rose.TYPE_LOGICAL_VALUE_TRUE, + ] + button.labels = [ + metomi.rose.TYPE_LOGICAL_FALSE_TITLE, + metomi.rose.TYPE_LOGICAL_TRUE_TITLE, + ] + button.set_tooltip_text(value_item) + if value_item in self.allowed_values: + index = self.allowed_values.index(value_item) + button.set_active(index) + button.set_image(self.make_log_image(index)) + button.set_label(button.labels[index]) + else: + button.set_inconsistent(True) + button.set_image(bad_img) + button.connect("toggled", self._switch_state_and_set) + button.show() + return button + + def _switch_state_and_set(self, widget): + state = self.allowed_values[widget.get_active()] + title = self.label_dict[state] + image = self.make_log_image(widget.get_active()) + widget.set_tooltip_text(state) + widget.set_label(title) + widget.set_image(image) + self.setter(widget) + + def populate_table(self): + """Populate a table with the array elements, dynamically.""" + focus = None + table_widgets = self.entries + for child in self.entry_table.get_children(): + if child.is_focus(): + focus = child + if len(self.entry_table.get_children()) < len(table_widgets): + # Newly added widget, set focus to the end + focus = self.entries[-1] + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + if ( + focus is None + and self.entry_table.is_focus() + and len(self.entries) > 0 + ): + focus = self.entries[-1] + num_fields = len(self.entries) + num_rows_now = 1 + (num_fields - 1) / self.num_allowed_columns + self.entry_table.resize(num_rows_now, self.num_allowed_columns) + if self.max_length.isdigit() and len(self.entries) >= int( + self.max_length + ): + self.add_button.hide() + else: + self.add_button.show() + if self.max_length.isdigit() and len(self.entries) <= int( + self.max_length + ): + self.del_button.hide() + else: + self.del_button.show() + if self.has_titles: + for col, label in enumerate(self.metadata["element-titles"]): + if col >= len(table_widgets): + break + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label = Gtk.Label(label=self.metadata["element-titles"][col]) + label.show() + widget.pack_start(label, expand=True, fill=True) + widget.show() + self.entry_table.attach( + widget, + col, + col + 1, + 0, + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + + for i, widget in enumerate(table_widgets): + row = i // self.num_allowed_columns + if self.has_titles: + row += 1 + column = i % self.num_allowed_columns + self.entry_table.attach( + widget, + column, + column + 1, + row, + row + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + self.grab_focus = lambda: self.hook.get_focus(self.entries[-1]) + self.check_resize() + + def reshape_table(self): + """Reshape a table according to the space allocated.""" + total_x_bound = self.entry_table.get_allocation().width + if not len(self.entries): + return False + entries_bound = sum([e.get_allocation().width for e in self.entries]) + each_entry_bound = entries_bound / len(self.entries) + maximum_entry_number = float(total_x_bound) / float(each_entry_bound) + rounded_max = int(maximum_entry_number) + 1 + if rounded_max != self.num_allowed_columns + 2 and rounded_max > 2: + self.num_allowed_columns = max(1, rounded_max - 2) + self.populate_table() + + def add_entry(self): + """Add a new button to the array.""" + entry = self.get_entry(self.allowed_values[0]) + self.entries.append(entry) + self.populate_table() + self.setter() + + def remove_entry(self, *args): + """Remove a button.""" + if len(self.entries) > 1: + self.entries.pop() + self.populate_table() + self.setter() + + def setter(self, *args): + """Update the value.""" + val_array = [] + for widget in self.entries: + value = widget.get_tooltip_text() + if value is None: + value = "" + val_array.append(value) + new_val = metomi.rose.variable.array_join(val_array) + self.value = new_val + self.set_value(new_val) + self.value_array = metomi.rose.variable.array_split(self.value) + return False diff --git a/metomi/rose/config_editor/valuewidget/array/mixed.py b/metomi/rose/config_editor/valuewidget/array/mixed.py new file mode 100644 index 0000000000..79a83ba930 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/mixed.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re +import sys +import math + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from . import entry +import metomi.rose.gtk.util +import metomi.rose.variable + + +class MixedArrayValueWidget(Gtk.Box): + """This is a class to represent a derived type variable as a table. + + The type (variable.metadata['type']) should be a list, e.g. + ['integer', 'real']. There can optionally be a length + (variable.metadata['length'] for derived type arrays. + + This will create a table containing different types (horizontally) + and different array elements (vertically). + + """ + + BAD_COLOUR = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR + ) + CHECK_NAME_IS_ELEMENT = re.compile(r".*\(\d+\)$").match + TIP_ADD = "Add array element" + TIP_DELETE = "Remove last array element" + TIP_INVALID_ENTRY = "Invalid entry - not {0}" + MIN_WIDTH_CHARS = 7 + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(MixedArrayValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.value_array = metomi.rose.variable.array_split(value) + self.extra_array = [] # For new rows + self.element_values = [] + self.rows = [] + self.widgets = [] + self.unlimited = metadata.get(metomi.rose.META_PROP_LENGTH) == ":" + if self.unlimited: + self.array_length = 1 + else: + self.array_length = metadata.get(metomi.rose.META_PROP_LENGTH, 1) + self.num_cols = len(metadata[metomi.rose.META_PROP_TYPE]) + self.types_row = [t for t in metadata[metomi.rose.META_PROP_TYPE]] + log_imgs = [ + (Gtk.STOCK_MEDIA_STOP, Gtk.IconSize.MENU), + (Gtk.STOCK_APPLY, Gtk.IconSize.MENU), + (Gtk.STOCK_DIALOG_WARNING, Gtk.IconSize.MENU), + ] + self.make_log_image = lambda i: Gtk.Image.new_from_stock(*log_imgs[i]) + + self.has_titles = False + if "element-titles" in metadata: + self.has_titles = True + + self.set_num_rows() + self.entry_table = Gtk.Table( + rows=self.num_rows, columns=self.num_cols, homogeneous=False + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + for i in range(self.num_rows): + self.insert_row(i) + self.normalise_width_widgets() + self.generate_buttons() + self.pack_start( + self.add_del_button_box, expand=False, fill=False, padding=0 + ) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.show() + + def set_num_rows(self): + """Derive the number of columns and rows.""" + if self.CHECK_NAME_IS_ELEMENT(self.metadata["id"]): + self.unlimited = False + if self.unlimited: + self.num_rows, rem = divmod(len(self.value_array), self.num_cols) + self.num_rows += [1, 0][rem == 0] + self.max_rows = sys.maxsize + else: + self.num_rows = int(self.array_length) + rem = divmod(len(self.value_array), self.num_cols)[1] + if self.num_rows == 0: + self.num_rows = 1 + self.max_rows = self.num_rows + if rem != 0: + # Then there is an incorrect number of entries. + # Display as entry box. + self.num_rows = 1 + self.max_rows = 1 + self.unlimited = False + self.types_row = ["_error_"] + self.value_array = [self.value] + self.has_titles = False + if self.num_rows == 0: + self.num_rows = 1 + if self.max_rows == 0: + self.max_rows = 1 + if self.has_titles: + self.num_rows += 1 + + def grab_focus(self): + if self.entry_table.get_focus_child() is None: + self.hook.get_focus(self.rows[-1][-1]) + else: + self.hook.get_focus(self.entry_table.get_focus_child()) + + def add_row(self, *args): + """Create a new row of widgets.""" + nrows = self.entry_table.child_get_property( + self.rows[-1][-1], "top-attach" + ) + self.entry_table.resize(nrows + 2, self.num_cols) + new_values = self.insert_row(nrows + 1) + if any(new_values): + self.value_array = self.value_array + new_values + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + self.set_num_rows() + self.normalise_width_widgets() + self._decide_show_buttons() + return False + + def get_focus_index(self): + text = "" + if not self.value_array: + return 0 + for i_row, widget_list in enumerate(self.rows): + for i, widget in enumerate(widget_list): + try: + val = self.value_array[i_row * self.num_cols + i] + except IndexError: + return None + prefix_text = entry.get_next_delimiter( + self.value[len(text) :], val + ) + if prefix_text is None: + return + if widget == self.entry_table.get_focus_child(): + if hasattr(widget, "get_focus_index"): + position = widget.get_focus_index() + return len(text + prefix_text) + position + else: + for child in widget.get_children(): + if not hasattr(child, "get_position"): + continue + position = child.get_position() + if self.types_row[i] in ["character", "quoted"]: + position += 1 + return len(text + prefix_text) + position + return len(text + prefix_text) + len(val) + text += prefix_text + val + return None + + def set_focus_index(self, focus_index=None): + """Set the focus and position within the table.""" + if focus_index is None: + return + value_array = metomi.rose.variable.array_split(self.value) + text = "" + widgets = [] + for widget_list in self.rows: + widgets.extend(widget_list) + types = self.types_row * len(self.rows) + if len(types) == 1: # Special invalid length widget + widgets[0].grab_focus() + if hasattr(widgets[0], "set_focus_index"): + widgets[0].set_focus_index(focus_index) + return + for i, val in enumerate(value_array): + prefix = entry.get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if len(text + prefix + val) >= focus_index: + if len(widgets) > i: + widgets[i].grab_focus() + val_offset = focus_index - len(text + prefix) + if hasattr(widgets[i], "set_focus_index"): + widgets[i].set_focus_index(val_offset) + return + text += prefix + val + + def del_row(self, *args): + """Delete the last row of widgets.""" + nrows = self.entry_table.child_get_property( + self.rows[-1][-1], "top-attach" + ) + for _ in enumerate(self.types_row): + ent = self.rows[-1][-1] + self.rows[-1].pop(-1) + self.entry_table.remove(ent) + self.rows.pop(-1) + self.entry_table.resize(nrows, self.num_cols) + chop_index = len(self.value_array) - self.num_cols + self.value_array = self.value_array[:chop_index] + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + self.set_num_rows() + self._decide_show_buttons() + return False + + def _decide_show_buttons(self): + """Show or hide the add row and delete row buttons.""" + if len(self.rows) >= self.max_rows and not self.unlimited: + self.add_button.hide() + self.del_button.show() + else: + self.add_button.show() + self.del_button.show() + if len(self.rows) == 1: + self.del_button.hide() + elif len(self.rows) == 2 and self.has_titles: + self.del_button.hide() + else: + self.add_button.show() + + def insert_row(self, row_index): + """Create a row of widgets from type_list.""" + widget_list = [] + new_values = [] + insert_row_index = row_index + for i, el_piece_type in enumerate(self.types_row): + if self.has_titles: + raw_index = row_index - 1 + else: + raw_index = row_index + unwrapped_index = raw_index * self.num_cols + i + value_index = unwrapped_index + while value_index > len(self.value_array) - 1: + value_index -= len(self.types_row) + if value_index < 0: + w_value = metomi.rose.variable.get_value_from_metadata( + {metomi.rose.META_PROP_TYPE: el_piece_type} + ) + else: + w_value = self.value_array[value_index] + new_values.append(w_value) + hover_text = "" + w_error = {} + if el_piece_type in ["integer", "real"]: + try: + [int, float][el_piece_type == "real"](w_value) + except (TypeError, ValueError): + if w_value != "": + hover_text = self.TIP_INVALID_ENTRY.format( + el_piece_type + ) + w_error = {metomi.rose.META_PROP_TYPE: hover_text} + w_meta = {metomi.rose.META_PROP_TYPE: el_piece_type} + widget_cls = metomi.rose.config_editor.valuewidget.chooser( + w_value, w_meta, w_error + ) + hook = self.hook + setter = ArrayElementSetter(self.setter, unwrapped_index) + if self.has_titles and row_index == 0: + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label = Gtk.Label(label=self.metadata["element-titles"][i]) + label.show() + widget.pack_start(label, expand=True, fill=True, padding=0) + else: + widget = widget_cls(w_value, w_meta, setter.set_value, hook) + if hover_text: + widget.set_tooltip_text(hover_text) + widget.show() + self.entry_table.attach( + widget, + i, + i + 1, + insert_row_index, + insert_row_index + 1, + xoptions=Gtk.AttachOptions.SHRINK, + yoptions=Gtk.AttachOptions.SHRINK, + ) + widget_list.append(widget) + self.rows.append(widget_list) + self.widgets.extend(widget_list) + return new_values + + def normalise_width_widgets(self): + if not self.rows: + return + for widget in self.rows[0]: + self._normalise_width_chars(widget) + + def _normalise_width_chars(self, widget): + index = self.widgets.index(widget) + element = index % self.num_cols + max_width = {} + # Get max width + for widgets in self.rows: + e_widget = widgets[element] + i = 0 + child_list = e_widget.get_children() + while child_list: + child = child_list.pop() + if ( + isinstance(child, Gtk.Label) + or isinstance(child, Gtk.Entry) + and hasattr(child, "get_text") + ): + width = len(child.get_text()) + if width > max_width.get(i, -1): + max_width.update({i: width}) + if hasattr(child, "get_children"): + child_list.extend(child.get_children()) + elif hasattr(child, "get_child"): + child_list.append(child.get_child()) + i += 1 + for key, value in list(max_width.items()): + if value < self.MIN_WIDTH_CHARS: + max_width[key] = self.MIN_WIDTH_CHARS + # Set max width + for widgets in self.rows: + e_widget = widgets[element] + i = 0 + child_list = e_widget.get_children() + while child_list: + child = child_list.pop() + if isinstance(child, Gtk.Entry) and hasattr( + child, "set_width_chars" + ): + child.set_width_chars(max_width[i]) + if hasattr(child, "get_children"): + child_list.extend(child.get_children()) + elif hasattr(child, "get_child"): + child_list.append(child.get_child()) + i += 1 + + def generate_buttons(self): + """Insert an add row and delete row button.""" + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_ADD) + self.del_button.add(del_image) + self.del_button.show() + self.del_button.connect("button-release-event", self.del_row) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.show() + self.add_button.connect("button-release-event", self.add_row) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.add_del_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add_del_button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.show() + self._decide_show_buttons() + + def setter(self, array_index, element_value): + """Update the value.""" + widget_row = self.rows[math.floor(array_index / self.num_cols)] + widget = widget_row[array_index % self.num_cols] + self._normalise_width_chars(widget) + i = array_index - len(self.value_array) + if i >= 0: + while len(self.extra_array) <= i: + self.extra_array.append("") + self.extra_array[i] = element_value + ok_index = 0 + j = self.num_cols + while j <= len(self.extra_array): + if len(self.extra_array[:j]) % self.num_cols == 0 and all( + self.extra_array[:j] + ): + ok_index = j + else: + break + j += self.num_cols + self.value_array.extend(self.extra_array[:ok_index]) + self.extra_array = self.extra_array[ok_index:] + else: + self.value_array[array_index] = element_value + new_val = metomi.rose.variable.array_join(self.value_array) + if new_val != self.value: + self.value = new_val + self.set_value(new_val) + + +class ArrayElementSetter(object): + """Element widget setter class.""" + + def __init__(self, setter_function, index): + self.setter_function = setter_function + self.index = index + + def set_value(self, value): + self.setter_function(self.index, value) diff --git a/metomi/rose/config_editor/valuewidget/array/python_list.py b/metomi/rose/config_editor/valuewidget/array/python_list.py new file mode 100644 index 0000000000..812a9d1993 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/python_list.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import ast + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from . import entry +import metomi.rose.config_editor.util +import metomi.rose.gtk.util +import metomi.rose.variable + + +class PythonListValueWidget(Gtk.Box): + """This is a class to represent a Python-compatible list format.""" + + TIP_ADD = "Add array element" + TIP_DEL = "Remove array element" + TIP_ELEMENT = "Element {0}" + TIP_ELEMENT_CHAR = "Element {0}: '{1}'" + TIP_LEFT = "Move array element left" + TIP_RIGHT = "Move array element right" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(PythonListValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.max_length = ":" + value_array = python_array_split(self.value) + self.chars_width = max([len(v) for v in value_array] + [1]) + 1 + self.last_selected_src = None + # Designate the number of allowed columns - 10 for 4 chars width + self.num_allowed_columns = 3 + self.entry_table = Gtk.Table( + rows=1, columns=self.num_allowed_columns, homogeneous=True + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + + self.entries = [] + self.generate_entries(value_array) + + self.has_titles = False + if "element-titles" in metadata: + self.has_titles = True + + self.generate_buttons() + self.populate_table() + self.pack_start( + self.add_del_button_box, expand=False, fill=False, padding=0 + ) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.entry_table.connect_after( + "size-allocate", lambda w, e: self.reshape_table() + ) + self.connect( + "focus-in-event", + lambda w, e: self.hook.get_focus(self.get_focus_entry()), + ) + + def force_scroll(self, widget=None): + """Adjusts a scrolled window to display the correct widget.""" + y_coordinate = None + if widget is not None: + y_coordinate = widget.get_allocation().y + scroll_container = widget.get_parent() + if scroll_container is None: + return False + while not isinstance(scroll_container, Gtk.ScrolledWindow): + scroll_container = scroll_container.get_parent() + vadj = scroll_container.get_vadjustment() + if y_coordinate == -1: # Bad allocation, don't scroll + return False + if y_coordinate is None: + vadj.set_upper(vadj.get_upper() + 0.08 * vadj.get_page_size()) + vadj.set_value(vadj.get_upper() - vadj.get_page_size()) + return False + vadj.set_value(y_coordinate) + return False + + def get_focus_entry(self): + """Get either the last selected entry or the last one.""" + if self.last_selected_src is not None: + return self.last_selected_src + if len(self.entries) > 0: + return self.entries[-1] + return None + + def get_focus_index(self): + """Get the focus and position within the table of entries.""" + if not self.value.startswith("["): + return + text = "[" + for my_entry in self.entries: + val = my_entry.get_text() + prefix = entry.get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if my_entry == self.entry_table.get_focus_child(): + return len(text + prefix) + my_entry.get_position() + text += prefix + val + return None + + def set_focus_index(self, focus_index=None): + """Set the focus and position within the table of entries.""" + if focus_index is None: + return + value_array = python_array_split(self.value) + if not self.value.startswith("["): + return + text = "[" + for i, val in enumerate(value_array): + prefix = entry.get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if ( + len(text + prefix + val) >= focus_index + or i == len(value_array) - 1 + ): + if len(self.entries) > i: + self.entries[i].grab_focus() + val_offset = focus_index - len(text + prefix) + self.entries[i].set_position(val_offset) + return + text += prefix + val + + def generate_entries(self, value_array=None): + """Create the Gtk.Entry objects for elements in the array.""" + if value_array is None: + value_array = python_array_split(self.value) + entries = [] + for value_item in value_array: + for widget in self.entries: + if widget.get_text() == value_item and widget not in entries: + entries.append(widget) + break + else: + entries.append(self.get_entry(value_item)) + self.entries = entries + + def generate_buttons(self): + """Create the left-right movement arrows and add button.""" + left_arrow = Gtk.ToolButton() + left_arrow.set_icon_name("pan-start-symbolic") + left_arrow.show() + left_arrow.connect("clicked", lambda x: self.move_element(-1)) + left_event_box = Gtk.EventBox() + left_event_box.add(left_arrow) + left_event_box.show() + left_event_box.set_tooltip_text(self.TIP_LEFT) + right_arrow = Gtk.ToolButton() + right_arrow.set_icon_name("pan-end-symbolic") + right_arrow.show() + right_arrow.connect("clicked", lambda x: self.move_element(1)) + right_event_box = Gtk.EventBox() + right_event_box.add(right_arrow) + right_event_box.show() + right_event_box.set_tooltip_text(self.TIP_RIGHT) + self.arrow_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.arrow_box.show() + self.arrow_box.pack_start( + left_event_box, expand=False, fill=False, padding=0 + ) + self.arrow_box.pack_end( + right_event_box, expand=False, fill=False, padding=0 + ) + self.set_arrow_sensitive(False, False) + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_DEL) + self.del_button.add(del_image) + self.del_button.show() + self.del_button.connect( + "button-release-event", lambda b, e: self.remove_entry() + ) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.button_box.show() + self.button_box.pack_start( + self.arrow_box, expand=False, fill=True, padding=0 + ) + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.show() + self.add_button.connect( + "button-release-event", lambda b, e: self.add_entry() + ) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.add_del_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add_del_button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.show() + + def set_arrow_sensitive(self, is_left_sensitive, is_right_sensitive): + """Control the sensitivity of the movement buttons.""" + sens_tuple = (is_left_sensitive, is_right_sensitive) + for i, event_box in enumerate(self.arrow_box.get_children()): + event_box.get_child().set_sensitive(sens_tuple[i]) + if not sens_tuple[i]: + event_box.set_state(Gtk.StateType.NORMAL) + + def move_element(self, num_places_right): + """Move the entry left or right.""" + widget = self.last_selected_src + if widget is None: + return + old_index = self.entries.index(widget) + if ( + old_index + num_places_right < 0 + or old_index + num_places_right > len(self.entries) - 1 + ): + return + self.entries.remove(widget) + self.entries.insert(old_index + num_places_right, widget) + self.populate_table() + self.setter(widget) + + def get_entry(self, value_item): + """Create a gtk Entry for this array element.""" + widget = Gtk.Entry() + widget.set_text(value_item) + widget.connect("focus-in-event", self._handle_focus_on_entry) + widget.connect("button-release-event", self._handle_middle_click_paste) + widget.connect_after("paste-clipboard", self.setter) + widget.connect_after("key-release-event", lambda e, v: self.setter(e)) + widget.connect_after( + "button-release-event", lambda e, v: self.setter(e) + ) + widget.connect("focus-out-event", self._handle_focus_off_entry) + widget.set_width_chars(self.chars_width - 1) + widget.show() + return widget + + def populate_table(self, focus_widget=None): + """Populate a table with the array elements, dynamically.""" + position = None + table_widgets = self.entries + [self.button_box] + table_children = self.entry_table.get_children() + if focus_widget is None: + for child in table_children: + if child.is_focus() and isinstance(child, Gtk.Entry): + focus_widget = child + position = focus_widget.get_position() + else: + position = focus_widget.get_position() + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + if ( + focus_widget is None + and self.entry_table.is_focus() + and len(self.entries) > 0 + ): + focus_widget = self.entries[-1] + position = len(focus_widget.get_text()) + num_fields = len(self.entries + [self.button_box]) + num_rows_now = 1 + (num_fields - 1) / self.num_allowed_columns + self.entry_table.resize(num_rows_now, self.num_allowed_columns) + if self.max_length.isdigit() and len(self.entries) >= int( + self.max_length + ): + self.add_button.hide() + else: + self.add_button.show() + if self.max_length.isdigit() and len(self.entries) <= int( + self.max_length + ): + self.del_button.hide() + elif len(self.entries) == 0: + self.del_button.hide() + else: + self.del_button.show() + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + index = self.entries.index(self.last_selected_src) + if index == 0: + self.set_arrow_sensitive(False, True) + elif index == len(self.entries) - 1: + self.set_arrow_sensitive(True, False) + if len(self.entries) < 2: + self.set_arrow_sensitive(False, False) + + if self.has_titles: + for col, label in enumerate(self.metadata["element-titles"]): + if col >= len(table_widgets) - 1: + break + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label = Gtk.Label(label=self.metadata["element-titles"][col]) + label.show() + widget.pack_start(label, expand=True, fill=True, padding=0) + widget.show() + self.entry_table.attach( + widget, + col, + col + 1, + 0, + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + + for i, widget in enumerate(table_widgets): + if isinstance(widget, Gtk.Entry): + widget.set_tooltip_text(self.TIP_ELEMENT.format((i + 1))) + row = i // self.num_allowed_columns + if self.has_titles: + row += 1 + column = i % self.num_allowed_columns + self.entry_table.attach( + widget, + column, + column + 1, + row, + row + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + if focus_widget is not None: + focus_widget.grab_focus() + focus_widget.set_position(position) + focus_widget.select_region(position, position) + self.grab_focus = lambda: self.hook.get_focus( + self._get_widget_for_focus() + ) + self.check_resize() + + def reshape_table(self): + """Reshape a table according to the space allocated.""" + total_x_bound = self.entry_table.get_allocation().width + if not len(self.entries): + return False + entries_bound = sum([e.get_allocation().width for e in self.entries]) + each_entry_bound = entries_bound / len(self.entries) + maximum_entry_number = float(total_x_bound) / float(each_entry_bound) + rounded_max = int(maximum_entry_number) + 1 + if rounded_max != self.num_allowed_columns + 2 and rounded_max > 2: + self.num_allowed_columns = max(1, rounded_max - 2) + self.populate_table() + + def add_entry(self): + """Add a new entry (with null text) to the variable array.""" + widget = self.get_entry("") + widget.connect("focus-in-event", lambda w, e: self.force_scroll(w)) + self.last_selected_src = widget + self.entries.append(widget) + self._adjust_entry_length() + self.populate_table(focus_widget=widget) + if ( + self.metadata.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + ): + self.setter(widget) + + def remove_entry(self): + """Remove the last selected or the last entry.""" + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + text = self.last_selected_src.get_text() + widget = self.entries.pop( + self.entries.index(self.last_selected_src) + ) + self.last_selected_src = None + else: + text = self.entries[-1].get_text() + widget = self.entries.pop() + self.populate_table() + if ( + self.metadata.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + or text + ): + # Optional, or compulsory but not blank. + self.setter(widget) + + def setter(self, widget): + """Reconstruct the new variable value from the entry array.""" + val_array = [] + # Prevent str without "" breaking the underlying Python syntax + for e in self.entries: + v = e.get_text() + if v in ("False", "True"): # Boolean + val_array.append(v) + elif (len(v) == 0) or (v[:1].isdigit()): # Empty or numeric + val_array.append(v) + elif not v.startswith('"'): # Str - add in leading and trailing " + val_array.append('"' + v + '"') + e.set_text('"' + v + '"') + e.set_position(len(v) + 1) + elif (not v.endswith('"')) or ( + len(v) == 1 + ): # Str - add in trailing " + val_array.append(v + '"') + e.set_text(v + '"') + e.set_position(len(v)) + else: + val_array.append(v) + max_length = max([len(v) for v in val_array] + [1]) + if max_length + 1 != self.chars_width: + self.chars_width = max_length + 1 + self._adjust_entry_length() + if widget is not None and not widget.is_focus(): + widget.grab_focus() + widget.set_position(len(widget.get_text())) + widget.select_region( + widget.get_position(), widget.get_position() + ) + entries_have_commas = any("," in v for v in val_array) + new_value = python_array_join(val_array) + if new_value != self.value: + self.value = new_value + self.set_value(new_value) + if entries_have_commas: + new_val_array = python_array_split(new_value) + if len(new_val_array) != len(self.entries): + self.generate_entries() + focus_index = None + for i, val in enumerate(val_array): + if "," in val: + val_post_comma = val[: val.index(",") + 1] + focus_index = len( + python_array_join( + new_val_array[:i] + [val_post_comma] + ) + ) + self.populate_table() + self.set_focus_index(focus_index) + return False + + def _adjust_entry_length(self): + for widget in self.entries: + widget.set_width_chars(self.chars_width) + widget.set_max_length(self.chars_width) + self.reshape_table() + + def _get_widget_for_focus(self): + if self.entries: + return self.entries[-1] + return self.entry_table + + def _handle_focus_off_entry(self, widget, event): + if widget == self.last_selected_src: + try: + widget.set_progress_fraction(1.0) + except AttributeError: + widget.drag_highlight() + if widget.get_position() is None: + widget.set_position(len(widget.get_text())) + + def _handle_focus_on_entry(self, widget, event): + if self.last_selected_src is not None: + try: + self.last_selected_src.set_progress_fraction(0.0) + except AttributeError: + self.last_selected_src.drag_unhighlight() + self.last_selected_src = widget + is_start = widget in self.entries and self.entries[0] == widget + is_end = widget in self.entries and self.entries[-1] == widget + self.set_arrow_sensitive(not is_start, not is_end) + if widget.get_text() != "": + widget.select_region(widget.get_position(), widget.get_position()) + return False + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self.setter(widget) + return False + + +def python_array_join(values): + """Create a Python-compliant list value from values.""" + return "[" + ", ".join(values) + "]" + + +def python_array_split(value): + """Split the value into elements with appropriate string values.""" + try: + value_array = ast.literal_eval(value) + except (SyntaxError, ValueError): + value_no_brackets = value.lstrip("[").rstrip("]") + value_array = metomi.rose.variable.array_split(value_no_brackets) + return value_array + cast_value_array = [] + for value in value_array: + if isinstance(value, str): + cast_value_array.append('"' + value + '"') + else: + cast_value_array.append(str(value)) + return cast_value_array diff --git a/metomi/rose/config_editor/valuewidget/array/row.py b/metomi/rose/config_editor/valuewidget/array/row.py new file mode 100644 index 0000000000..d00bb5f515 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/row.py @@ -0,0 +1,506 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import re +import sys +import math + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from . import entry +import metomi.rose.gtk.util +import metomi.rose.variable + + +class RowArrayValueWidget(Gtk.Box): + """This is a class to represent a value as part of a row.""" + + BAD_COLOUR = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR + ) + CHECK_NAME_IS_ELEMENT = re.compile(r".*\(\d+\)$").match + TIP_ADD = "Add array element" + TIP_DELETE = "Remove last array element" + TIP_INVALID_ENTRY = "Invalid entry - not {0}" + MIN_WIDTH_CHARS = 7 + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(RowArrayValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.value_array = metomi.rose.variable.array_split(value) + self.extra_array = [] # For new rows + self.element_values = [] + self.rows = [] + self.widgets = [] + self.has_length_error = False + self.length = metadata.get(metomi.rose.META_PROP_LENGTH) + self.type = metadata.get(metomi.rose.META_PROP_TYPE, "raw") + self.num_cols = len(self.value_array) + if arg_str is None: + if isinstance(self.type, list): + self.num_cols = len(self.type) + elif self.length is not None and self.length.isdigit(): + self.num_cols = int(self.length) + else: + self.num_cols = int(arg_str) + self.unlimited = self.length == ":" + if self.unlimited: + self.array_length = 1 + else: + self.array_length = metadata.get(metomi.rose.META_PROP_LENGTH, 1) + log_imgs = [ + (Gtk.STOCK_MEDIA_STOP, Gtk.IconSize.MENU), + (Gtk.STOCK_APPLY, Gtk.IconSize.MENU), + (Gtk.STOCK_DIALOG_WARNING, Gtk.IconSize.MENU), + ] + self.make_log_image = lambda i: Gtk.Image.new_from_stock(*log_imgs[i]) + self.set_num_rows() + self.entry_table = Gtk.Table( + rows=self.num_rows, columns=self.num_cols, homogeneous=True + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + for i in range(self.num_rows): + self.insert_row(i) + self.normalise_width_widgets() + self.generate_buttons(is_for_elements=not isinstance(self.type, list)) + self.pack_start( + self.add_del_button_box, expand=False, fill=False, padding=0 + ) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.show() + + def set_num_rows(self): + """Derive the number of columns and rows.""" + if not isinstance(self.type, list): + self.num_rows = 1 + self.max_rows = 1 + self.unlimited = False + return + columns = len(self.type) + if self.CHECK_NAME_IS_ELEMENT(self.metadata["id"]): + self.unlimited = False + if self.unlimited: + self.num_rows, rem = divmod(len(self.value_array), columns) + self.num_rows += [1, 0][rem == 0] + self.max_rows = sys.maxsize + else: + self.num_rows = int(self.array_length) + rem = divmod(len(self.value_array), columns)[1] + if self.num_rows == 0: + self.num_rows = 1 + self.max_rows = self.num_rows + if rem != 0: + # Then there is an incorrect number of entries. + # Display as entry box. + self.num_rows = 1 + self.max_rows = 1 + self.unlimited = False + self.has_length_error = True + self.value_array = [self.value] + if self.num_rows == 0: + self.num_rows = 1 + if self.max_rows == 0: + self.max_rows = 1 + + def get_type(self, index): + """Get the metadata type for this value index.""" + return self.get_types()[index] + + def get_types(self): + """Get a list of metadata types for the value.""" + if isinstance(self.type, list): + return self.type + return [self.type] * self.num_cols + + def grab_focus(self): + if self.entry_table.get_focus_child() is None: + self.hook.get_focus(self.rows[-1][-1]) + else: + self.hook.get_focus(self.entry_table.get_focus_child()) + + def add_element(self, *args): + """Create a new element (non-derived types).""" + w_value = metomi.rose.variable.get_value_from_metadata( + {metomi.rose.META_PROP_TYPE: self.type} + ) + self.value_array = self.value_array + [w_value] + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + for i in range(self.num_rows): + self.insert_row(i) + self.normalise_width_widgets() + self._decide_show_buttons() + + def add_row(self, *args): + """Create a new row of widgets.""" + nrows = self.entry_table.child_get_property( + self.rows[-1][-1], "top-attach" + ) + self.entry_table.resize(nrows + 2, self.num_cols) + new_values = self.insert_row(nrows + 1) + if any(new_values): + self.value_array = self.value_array + new_values + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + self.set_num_rows() + self.normalise_width_widgets() + self._decide_show_buttons() + return False + + def get_focus_index(self): + text = "" + for i, widget_list in enumerate(self.rows): + for j, widget in enumerate(widget_list): + value_index = i * self.num_cols + j + if value_index > len(self.value_array) - 1: + return len(text) + val = self.value_array[i * self.num_cols + j] + prefix_text = entry.get_next_delimiter( + self.value[len(text) :], val + ) + if prefix_text is None: + return + if widget == self.entry_table.get_focus_child(): + if hasattr(widget, "get_focus_index"): + position = widget.get_focus_index() + return len(text + prefix_text) + position + else: + for child in widget.get_children(): + if not hasattr(child, "get_position"): + continue + position = child.get_position() + if self.get_type(j) in ["character", "quoted"]: + position += 1 + return len(text + prefix_text) + position + return len(text + prefix_text) + len(val) + text += prefix_text + val + return None + + def set_focus_index(self, focus_index=None): + """Set the focus and position within the table.""" + if focus_index is None: + return + value_array = metomi.rose.variable.array_split(self.value) + text = "" + widgets = [] + for widget_list in self.rows: + widgets.extend(widget_list) + if self.has_length_error: # Special invalid length widget + widgets[0].grab_focus() + if hasattr(widgets[0], "set_focus_index"): + widgets[0].set_focus_index(focus_index) + return + for i, val in enumerate(value_array): + prefix = entry.get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if len(text + prefix + val) >= focus_index: + if len(widgets) > i: + widgets[i].grab_focus() + val_offset = focus_index - len(text + prefix) + if hasattr(widgets[i], "set_focus_index"): + widgets[i].set_focus_index(val_offset) + return + text += prefix + val + + def del_element(self, *args): + """Create a new element (non-derived types).""" + self.value_array.pop() + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + for i in range(self.num_rows): + self.insert_row(i) + self.normalise_width_widgets() + self._decide_show_buttons() + + def del_row(self, *args): + """Delete the last row of widgets.""" + nrows = self.entry_table.child_get_property( + self.rows[-1][-1], "top-attach" + ) + for _ in enumerate(self.get_types()): + widget = self.rows[-1][-1] + self.rows[-1].pop(-1) + self.entry_table.remove(widget) + self.rows.pop(-1) + self.entry_table.resize(nrows, self.num_cols) + + chop_index = len(self.value_array) - len(self.get_types()) + self.value_array = self.value_array[:chop_index] + self.value = metomi.rose.variable.array_join(self.value_array) + self.set_value(self.value) + self.set_num_rows() + self.normalise_width_widgets() + self._decide_show_buttons() + return False + + def _decide_show_buttons(self): + # Show or hide the add row and delete row buttons. + if isinstance(self.type, list): + if len(self.rows) >= self.max_rows and not self.unlimited: + self.add_button.hide() + self.del_button.show() + else: + self.add_button.show() + self.del_button.show() + if len(self.rows) == 1: + self.del_button.hide() + else: + self.add_button.show() + else: + if ( + self.length is not None + and self.length.isdigit() + and len(self.value_array) >= int(self.length) + ): + self.add_button.hide() + self.del_button.show() + else: + self.add_button.show() + self.del_button.show() + if len(self.value_array) == 1: + self.del_button.hide() + + def insert_row(self, row_index): + """Create a row of widgets from type_list.""" + widget_list = [] + new_values = [] + actual_num_cols = len(self.get_types()) + for i, el_piece_type in enumerate(self.get_types()): + unwrapped_index = row_index * actual_num_cols + i + value_index = unwrapped_index + if not isinstance(self.type, list) and value_index >= len( + self.value_array + ): + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + eb0 = Gtk.EventBox() + eb0.show() + widget.pack_start(eb0, expand=True, fill=True, padding=0) + widget.show() + self.entry_table.attach( + widget, + i, + i + 1, + row_index, + row_index + 1, + xoptions=( + Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL + ), + yoptions=Gtk.AttachOptions.SHRINK, + ) + widget_list.append(widget) + continue + while value_index > len(self.value_array) - 1: + value_index -= actual_num_cols + if value_index < 0: + w_value = metomi.rose.variable.get_value_from_metadata( + {metomi.rose.META_PROP_TYPE: el_piece_type} + ) + else: + w_value = self.value_array[value_index] + new_values.append(w_value) + hover_text = "" + w_error = {} + if el_piece_type in ["integer", "real"]: + try: + [int, float][el_piece_type == "real"](w_value) + except (TypeError, ValueError): + if w_value != "": + hover_text = self.TIP_INVALID_ENTRY.format( + el_piece_type + ) + w_error = {metomi.rose.META_PROP_TYPE: hover_text} + w_meta = {metomi.rose.META_PROP_TYPE: el_piece_type} + widget_cls = metomi.rose.config_editor.valuewidget.chooser( + w_value, w_meta, w_error + ) + hook = self.hook + setter = ArrayElementSetter(self.setter, unwrapped_index) + widget = widget_cls(w_value, w_meta, setter.set_value, hook) + if hover_text: + widget.set_tooltip_text(hover_text) + widget.show() + self.entry_table.attach( + widget, + i, + i + 1, + row_index, + row_index + 1, + xoptions=(Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL), + yoptions=Gtk.AttachOptions.SHRINK, + ) + widget_list.append(widget) + self.rows.append(widget_list) + self.widgets.extend(widget_list) + return new_values + + def normalise_width_widgets(self): + if not self.rows: + return + for widget in self.rows[0]: + self._normalise_width_chars(widget) + + def _normalise_width_chars(self, widget): + index = self.widgets.index(widget) + element = index % self.num_cols + max_width = {} + # Get max width + for widgets in self.rows: + if element >= len(widgets): + continue + e_widget = widgets[element] + i = 0 + child_list = e_widget.get_children() + while child_list: + child = child_list.pop() + if isinstance(child, Gtk.Entry) and hasattr(child, "get_text"): + width = len(child.get_text()) + if width > max_width.get(i, -1): + max_width.update({i: width}) + if hasattr(child, "get_children"): + child_list.extend(child.get_children()) + elif hasattr(child, "get_child"): + child_list.append(child.get_child()) + i += 1 + for key, value in list(max_width.items()): + if value < self.MIN_WIDTH_CHARS: + max_width[key] = self.MIN_WIDTH_CHARS + # Set max width + for widgets in self.rows: + if element >= len(widgets): + continue + e_widget = widgets[element] + i = 0 + child_list = e_widget.get_children() + while child_list: + child = child_list.pop() + if isinstance(child, Gtk.Entry) and hasattr( + child, "set_width_chars" + ): + child.set_width_chars(max_width[i]) + if hasattr(child, "get_children"): + child_list.extend(child.get_children()) + elif hasattr(child, "get_child"): + child_list.append(child.get_child()) + i += 1 + + def generate_buttons(self, is_for_elements=False): + """Insert an add row and delete row button.""" + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_DELETE) + self.del_button.add(del_image) + self.del_button.show() + if is_for_elements: + delete_func = self.del_element + else: + delete_func = self.del_row + self.del_button.connect("button-release-event", delete_func) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.show() + if is_for_elements: + add_func = self.add_element + else: + add_func = self.add_row + self.add_button.connect("button-release-event", add_func) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.add_del_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add_del_button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.show() + self._decide_show_buttons() + + def setter(self, array_index, element_value): + """Update the value.""" + actual_num_cols = len(self.get_types()) + widget_row = self.rows[math.floor(array_index / actual_num_cols)] + widget = widget_row[array_index % actual_num_cols] + self._normalise_width_chars(widget) + i = array_index - len(self.value_array) + if i >= 0: + while len(self.extra_array) <= i: + self.extra_array.append("") + self.extra_array[i] = element_value + ok_index = 0 + j = self.num_cols + while j <= len(self.extra_array): + if len(self.extra_array[:j]) % self.num_cols == 0 and all( + self.extra_array[:j] + ): + ok_index = j + else: + break + j += self.num_cols + self.value_array.extend(self.extra_array[:ok_index]) + self.extra_array = self.extra_array[ok_index:] + else: + self.value_array[array_index] = element_value + new_val = metomi.rose.variable.array_join(self.value_array) + if new_val != self.value: + self.value = new_val + self.set_value(new_val) + + +class ArrayElementSetter(object): + """Element widget setter class.""" + + def __init__(self, setter_function, index): + self.setter_function = setter_function + self.index = index + + def set_value(self, value): + self.setter_function(self.index, value) diff --git a/metomi/rose/config_editor/valuewidget/array/spaced_list.py b/metomi/rose/config_editor/valuewidget/array/spaced_list.py new file mode 100644 index 0000000000..a2219e1f09 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/array/spaced_list.py @@ -0,0 +1,529 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import shlex + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor.util +import metomi.rose.gtk.util +import metomi.rose.variable + + +class SpacedListValueWidget(Gtk.Box): + """This is a class to represent a list separated by spaces.""" + + TIP_ADD = "Add array element" + TIP_DEL = "Remove array element" + TIP_ELEMENT = "Element {0}" + TIP_ELEMENT_CHAR = "Element {0}: '{1}'" + TIP_LEFT = "Move array element left" + TIP_RIGHT = "Move array element right" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(SpacedListValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.last_value = value + self.max_length = ":" + value_array = spaced_array_split(self.value) + self.chars_width = max([len(str(v)) for v in value_array] + [1]) + 1 + self.last_selected_src = None + # Designate the number of allowed columns - 10 for 4 chars width + self.num_allowed_columns = 3 + self.entry_table = Gtk.Table( + rows=1, columns=self.num_allowed_columns, homogeneous=True + ) + self.entry_table.connect("focus-in-event", self.hook.trigger_scroll) + self.entry_table.show() + + self.entries = [] + self.generate_entries(value_array) + + self.has_titles = False + if "element-titles" in metadata: + self.has_titles = True + + self.generate_buttons() + self.populate_table() + self.pack_start( + self.add_del_button_box, expand=False, fill=False, padding=0 + ) + self.pack_start(self.entry_table, expand=True, fill=True, padding=0) + self.entry_table.connect_after( + "size-allocate", lambda w, e: self.reshape_table() + ) + self.connect( + "focus-in-event", + lambda w, e: self.hook.get_focus(self.get_focus_entry()), + ) + + def force_scroll(self, widget=None): + """Adjusts a scrolled window to display the correct widget.""" + y_coordinate = None + if widget is not None: + y_coordinate = widget.get_allocation().y + scroll_container = widget.get_parent() + if scroll_container is None: + return False + while not isinstance(scroll_container, Gtk.ScrolledWindow): + scroll_container = scroll_container.get_parent() + vadj = scroll_container.get_vadjustment() + if y_coordinate == -1: # Bad allocation, don't scroll + return False + if y_coordinate is None: + vadj.set_upper(vadj.get_upper() + 0.08 * vadj.get_page_size()) + vadj.set_value(vadj.get_upper() - vadj.get_page_size()) + return False + vadj.set_value(y_coordinate) + return False + + def get_focus_entry(self): + """Get either the last selected entry or the last one.""" + if self.last_selected_src is not None: + return self.last_selected_src + if len(self.entries) > 0: + return self.entries[-1] + return None + + def get_focus_index(self): + """Get the focus and position within the table of entries.""" + text = "" + for my_entry in self.entries: + val = my_entry.get_text() + prefix = get_next_delimiter(self.value[len(text) :], val) + if prefix is None: + return + if my_entry == self.entry_table.get_focus_child(): + return len(text + prefix) + my_entry.get_position() + text += prefix + val + return None + + def set_focus_index(self, focus_index=None): + """Set the focus and position within the table of entries.""" + if focus_index is None: + return + value_array = spaced_array_split(self.value) + value_array_old = spaced_array_split(self.last_value) + for i, val in enumerate(value_array): + if i >= len(value_array_old): + self.entries[len(value_array) - 1].grab_focus() + break + if val != value_array_old[i]: + self.entries[i].grab_focus() + break + + def generate_entries(self, value_array=None): + """Create the Gtk.Entry objects for elements in the array.""" + if value_array is None: + value_array = spaced_array_split(self.value) + entries = [] + for value_item in value_array: + for entry in self.entries: + if entry.get_text() == value_item and entry not in entries: + entries.append(entry) + break + else: + entries.append(self.get_entry(value_item)) + self.entries = entries + + def generate_buttons(self): + """Create the left-right movement arrows and add button.""" + left_arrow = Gtk.ToolButton() + left_arrow.set_icon_name("pan-start-symbolic") + left_arrow.show() + left_arrow.connect("clicked", lambda x: self.move_element(-1)) + left_event_box = Gtk.EventBox() + left_event_box.add(left_arrow) + left_event_box.show() + left_event_box.set_tooltip_text(self.TIP_LEFT) + right_arrow = Gtk.ToolButton() + right_arrow.set_icon_name("pan-end-symbolic") + right_arrow.show() + right_arrow.connect("clicked", lambda x: self.move_element(1)) + right_event_box = Gtk.EventBox() + right_event_box.add(right_arrow) + right_event_box.show() + right_event_box.set_tooltip_text(self.TIP_RIGHT) + self.arrow_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.arrow_box.show() + self.arrow_box.pack_start( + left_event_box, expand=False, fill=False, padding=0 + ) + self.arrow_box.pack_end( + right_event_box, expand=False, fill=False, padding=0 + ) + self.set_arrow_sensitive(False, False) + del_image = Gtk.Image.new_from_stock( + Gtk.STOCK_REMOVE, Gtk.IconSize.MENU + ) + del_image.show() + self.del_button = Gtk.EventBox() + self.del_button.set_tooltip_text(self.TIP_DEL) + self.del_button.add(del_image) + self.del_button.show() + self.del_button.connect( + "button-release-event", lambda b, e: self.remove_entry() + ) + self.del_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.del_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.button_box.show() + self.button_box.pack_start( + self.arrow_box, expand=False, fill=True, padding=0 + ) + add_image = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + add_image.show() + self.add_button = Gtk.EventBox() + self.add_button.set_tooltip_text(self.TIP_ADD) + self.add_button.add(add_image) + self.add_button.show() + self.add_button.connect( + "button-release-event", lambda b, e: self.add_entry() + ) + self.add_button.connect( + "enter-notify-event", + lambda b, e: b.set_state(Gtk.StateType.ACTIVE), + ) + self.add_button.connect( + "leave-notify-event", + lambda b, e: b.set_state(Gtk.StateType.NORMAL), + ) + self.add_del_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add_del_button_box.pack_start( + self.add_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.pack_start( + self.del_button, expand=False, fill=False, padding=0 + ) + self.add_del_button_box.show() + + def set_arrow_sensitive(self, is_left_sensitive, is_right_sensitive): + """Control the sensitivity of the movement buttons.""" + sens_tuple = (is_left_sensitive, is_right_sensitive) + for i, event_box in enumerate(self.arrow_box.get_children()): + event_box.get_child().set_sensitive(sens_tuple[i]) + if not sens_tuple[i]: + event_box.set_state(Gtk.StateType.NORMAL) + + def move_element(self, num_places_right): + """Move the entry left or right.""" + entry = self.last_selected_src + if entry is None: + return + old_index = self.entries.index(entry) + if ( + old_index + num_places_right < 0 + or old_index + num_places_right > len(self.entries) - 1 + ): + return + self.entries.remove(entry) + self.entries.insert(old_index + num_places_right, entry) + self.populate_table() + self.setter(entry) + + def get_entry(self, value_item): + """Create a gtk Entry for this array element.""" + entry = Gtk.Entry() + entry.set_text(str(value_item)) + entry.connect("focus-in-event", self._handle_focus_on_entry) + entry.connect("button-release-event", self._handle_middle_click_paste) + entry.connect_after("paste-clipboard", self.setter) + entry.connect_after("key-release-event", lambda e, v: self.setter(e)) + entry.connect_after( + "button-release-event", lambda e, v: self.setter(e) + ) + entry.connect("focus-out-event", self._handle_focus_off_entry) + entry.set_width_chars(self.chars_width - 1) + entry.show() + return entry + + def populate_table(self, focus_widget=None): + """Populate a table with the array elements, dynamically.""" + position = None + table_widgets = self.entries + [self.button_box] + table_children = self.entry_table.get_children() + if focus_widget is None: + for child in table_children: + if child.is_focus() and isinstance(child, Gtk.Entry): + focus_widget = child + position = focus_widget.get_position() + else: + position = focus_widget.get_position() + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + if ( + focus_widget is None + and self.entry_table.is_focus() + and len(self.entries) > 0 + ): + focus_widget = self.entries[-1] + position = len(focus_widget.get_text()) + num_fields = len(self.entries + [self.button_box]) + num_rows_now = 1 + (num_fields - 1) / self.num_allowed_columns + self.entry_table.resize(num_rows_now, self.num_allowed_columns) + if self.max_length.isdigit() and len(self.entries) >= int( + self.max_length + ): + self.add_button.hide() + else: + self.add_button.show() + if self.max_length.isdigit() and len(self.entries) <= int( + self.max_length + ): + self.del_button.hide() + elif len(self.entries) == 0: + self.del_button.hide() + else: + self.del_button.show() + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + index = self.entries.index(self.last_selected_src) + if index == 0: + self.set_arrow_sensitive(False, True) + elif index == len(self.entries) - 1: + self.set_arrow_sensitive(True, False) + if len(self.entries) < 2: + self.set_arrow_sensitive(False, False) + + if self.has_titles: + for col, label in enumerate(self.metadata["element-titles"]): + if col >= len(table_widgets) - 1: + break + widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label = Gtk.Label(label=self.metadata["element-titles"][col]) + label.show() + widget.pack_start(label, expand=True, fill=True, padding=0) + widget.show() + self.entry_table.attach( + widget, + col, + col + 1, + 0, + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + + for i, widget in enumerate(table_widgets): + if isinstance(widget, Gtk.Entry): + widget.set_tooltip_text(self.TIP_ELEMENT.format((i + 1))) + row = i // self.num_allowed_columns + if self.has_titles: + row += 1 + column = i % self.num_allowed_columns + self.entry_table.attach( + widget, + column, + column + 1, + row, + row + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.SHRINK, + ) + if focus_widget is not None: + focus_widget.grab_focus() + focus_widget.set_position(position) + focus_widget.select_region(position, position) + self.grab_focus = lambda: self.hook.get_focus( + self._get_widget_for_focus() + ) + self.check_resize() + + def reshape_table(self): + """Reshape a table according to the space allocated.""" + total_x_bound = self.entry_table.get_allocation().width + if not len(self.entries): + return False + entries_bound = sum([e.get_allocation().width for e in self.entries]) + each_entry_bound = entries_bound / len(self.entries) + maximum_entry_number = float(total_x_bound) / float(each_entry_bound) + rounded_max = int(maximum_entry_number) + 1 + if rounded_max != self.num_allowed_columns + 2 and rounded_max > 2: + self.num_allowed_columns = max(1, rounded_max - 2) + self.populate_table() + + def add_entry(self): + """Add a new entry (with null text) to the variable array.""" + entry = self.get_entry("") + entry.connect("focus-in-event", lambda w, e: self.force_scroll(w)) + self.last_selected_src = entry + self.entries.append(entry) + self._adjust_entry_length() + self.populate_table(focus_widget=entry) + if ( + self.metadata.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + ): + self.setter(entry) + + def remove_entry(self): + """Remove the last selected or the last entry.""" + if ( + self.last_selected_src is not None + and self.last_selected_src in self.entries + ): + text = self.last_selected_src.get_text() + entry = self.entries.pop( + self.entries.index(self.last_selected_src) + ) + self.last_selected_src = None + else: + text = self.entries[-1].get_text() + entry = self.entries.pop() + self.populate_table() + if ( + self.metadata.get(metomi.rose.META_PROP_COMPULSORY) + != metomi.rose.META_PROP_VALUE_TRUE + or text + ): + # Optional, or compulsory but not blank. + self.setter(entry) + + def setter(self, widget): + """Reconstruct the new variable value from the entry array.""" + val_array = [] + # Prevent str without "" breaking the underlying Python syntax + for e in self.entries: + v = e.get_text() + if v in ("False", "True"): # Boolean + val_array.append(v) + elif (len(v) == 0) or (v[:1].isdigit()): # Empty or numeric + val_array.append(v) + elif not v.startswith('"'): # Str - add in leading and trailing " + val_array.append('"' + v + '"') + e.set_text('"' + v + '"') + e.set_position(len(v) + 1) + elif (not v.endswith('"')) or ( + len(v) == 1 + ): # Str - add in trailing " + val_array.append(v + '"') + e.set_text(v + '"') + e.set_position(len(v)) + else: + val_array.append(v) + max_length = max([len(v) for v in val_array] + [1]) + if max_length + 1 != self.chars_width: + self.chars_width = max_length + 1 + self._adjust_entry_length() + if widget is not None and not widget.is_focus(): + widget.grab_focus() + widget.set_position(len(widget.get_text())) + widget.select_region( + widget.get_position(), widget.get_position() + ) + entries_have_spaces = any(" " in v for v in val_array) + new_value = spaced_array_join(val_array) + if new_value != self.value: + self.last_value = self.value + self.value = new_value + self.set_value(new_value) + if entries_have_spaces: + new_val_array = spaced_array_split(new_value) + if len(new_val_array) != len(self.entries): + self.generate_entries() + focus_index = None + for i, val in enumerate(val_array): + if "" in val: + val_post_comma = val[: val.index("") + 1] + focus_index = len( + spaced_array_join( + new_val_array[:i] + [val_post_comma] + ) + ) + self.populate_table() + self.set_focus_index(focus_index) + return False + + def _adjust_entry_length(self): + for entry in self.entries: + entry.set_width_chars(self.chars_width) + self.reshape_table() + + def _get_widget_for_focus(self): + if self.entries: + return self.entries[-1] + return self.entry_table + + def _handle_focus_off_entry(self, widget, event): + if widget == self.last_selected_src: + try: + widget.set_progress_fraction(1.0) + except AttributeError: + widget.drag_highlight() + if widget.get_position() is None: + widget.set_position(len(widget.get_text())) + + def _handle_focus_on_entry(self, widget, event): + if self.last_selected_src is not None: + try: + self.last_selected_src.set_progress_fraction(0.0) + except AttributeError: + self.last_selected_src.drag_unhighlight() + self.last_selected_src = widget + is_start = widget in self.entries and self.entries[0] == widget + is_end = widget in self.entries and self.entries[-1] == widget + self.set_arrow_sensitive(not is_start, not is_end) + if widget.get_text() != "": + widget.select_region(widget.get_position(), widget.get_position()) + return False + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self.setter(widget) + return False + + +def get_next_delimiter(array_text, next_element): + """Return the part of array_text immediately preceding next_element.""" + try: + val = array_text.index(next_element) + except ValueError: + return + return array_text[:val] + + +def spaced_array_join(values): + """Create a Spaced-compliant list value from values.""" + return " ".join(values) + + +def spaced_array_split(value): + """Split the value into elements with appropriate string values.""" + try: + value_array = shlex.split(value) + except (SyntaxError, ValueError): + value_array = metomi.rose.variable.array_split(value) + return value_array diff --git a/metomi/rose/config_editor/valuewidget/boolradio.py b/metomi/rose/config_editor/valuewidget/boolradio.py new file mode 100644 index 0000000000..e78df701f7 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/boolradio.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor +from . import radiobuttons + + +class BoolValueWidget(radiobuttons.RadioButtonsValueWidget): + """Produces 'true' and 'false' labelled radio buttons.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(BoolValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.allowed_values = [] + self.label_dict = {} + if metadata.get(metomi.rose.META_PROP_TYPE) == "boolean": + self.allowed_values = [ + metomi.rose.TYPE_BOOLEAN_VALUE_TRUE, + metomi.rose.TYPE_BOOLEAN_VALUE_FALSE, + ] + else: + self.allowed_values = [ + metomi.rose.TYPE_LOGICAL_VALUE_TRUE, + metomi.rose.TYPE_LOGICAL_VALUE_FALSE, + ] + self.label_dict = { + metomi.rose.TYPE_LOGICAL_VALUE_TRUE: ( + metomi.rose.TYPE_LOGICAL_TRUE_TITLE + ), + metomi.rose.TYPE_LOGICAL_VALUE_FALSE: ( + metomi.rose.TYPE_LOGICAL_FALSE_TITLE + ), + } + + for k, item in enumerate(self.allowed_values): + if item in self.label_dict: + button_label = str(self.label_dict[item]) + else: + button_label = str(item) + self.label_dict.update({item: button_label}) + if k == 0: + radio_button = Gtk.RadioButton( + group=None, label=button_label, use_underline=False + ) + radio_button.real_value = item + else: + radio_button = Gtk.RadioButton( + group=radio_button, label=button_label, use_underline=False + ) + radio_button.real_value = item + radio_button.set_active(False) + if item == str(value): + radio_button.set_active(True) + radio_button.connect("toggled", self.setter) + self.pack_start(radio_button, False, False, 10) + radio_button.show() + radio_button.connect("focus-in-event", self.hook.trigger_scroll) + self.grab_focus = lambda: self.hook.get_focus(radio_button) + + def setter(self, widget, variable): + if widget.get_active(): + label_value = widget.get_label() + for real_item, label in list(self.label_dict.items()): + if label == label_value: + chosen_value = real_item + break + self.value = chosen_value + self.set_value(chosen_value) + return False diff --git a/metomi/rose/config_editor/valuewidget/booltoggle.py b/metomi/rose/config_editor/valuewidget/booltoggle.py new file mode 100644 index 0000000000..e6139d3bf9 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/booltoggle.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose + + +class BoolToggleValueWidget(Gtk.Box): + """Produces a 'true' and 'false' labelled toggle button.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(BoolToggleValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.allowed_values = [] + self.label_dict = {} + if metadata.get(metomi.rose.META_PROP_TYPE) == "boolean": + self.allowed_values = [ + metomi.rose.TYPE_BOOLEAN_VALUE_FALSE, + metomi.rose.TYPE_BOOLEAN_VALUE_TRUE, + ] + self.label_dict = dict( + list(zip(self.allowed_values, self.allowed_values)) + ) + elif metadata.get(metomi.rose.META_PROP_TYPE) == "python_boolean": + self.allowed_values = [ + metomi.rose.TYPE_PYTHON_BOOLEAN_VALUE_FALSE, + metomi.rose.TYPE_PYTHON_BOOLEAN_VALUE_TRUE, + ] + self.label_dict = dict( + list(zip(self.allowed_values, self.allowed_values)) + ) + else: + self.allowed_values = [ + metomi.rose.TYPE_LOGICAL_VALUE_FALSE, + metomi.rose.TYPE_LOGICAL_VALUE_TRUE, + ] + self.label_dict = { + metomi.rose.TYPE_LOGICAL_VALUE_FALSE: ( + metomi.rose.TYPE_LOGICAL_FALSE_TITLE + ), + metomi.rose.TYPE_LOGICAL_VALUE_TRUE: ( + metomi.rose.TYPE_LOGICAL_TRUE_TITLE + ), + } + + imgs = [ + Gtk.Image.new_from_stock(Gtk.STOCK_MEDIA_STOP, Gtk.IconSize.MENU), + Gtk.Image.new_from_stock(Gtk.STOCK_APPLY, Gtk.IconSize.MENU), + ] + self.image_dict = dict(list(zip(self.allowed_values, imgs))) + bad_img = Gtk.Image.new_from_stock( + Gtk.STOCK_DIALOG_WARNING, Gtk.IconSize.MENU + ) + self.button = Gtk.ToggleButton(label=self.value) + if self.value in self.allowed_values: + self.button.set_active(self.allowed_values.index(self.value)) + self.button.set_label(self.label_dict[self.value]) + self.button.set_image(self.image_dict[self.value]) + else: + self.button.set_inconsistent(True) + self.button.set_image(bad_img) + self.button.connect("toggled", self._switch_state_and_set) + self.button.show() + self.pack_start(self.button, expand=False, fill=False, padding=0) + self.grab_focus = lambda: self.hook.get_focus(self.button) + self.button.connect("focus-in-event", self.hook.trigger_scroll) + + def _switch_state_and_set(self, widget): + state = self.allowed_values[int(widget.get_active())] + title = self.label_dict[state] + image = self.image_dict[state] + widget.set_label(title) + widget.set_image(image) + self.setter(widget) + + def setter(self, widget): + label_value = widget.get_label() + for real_item, label in list(self.label_dict.items()): + if label == label_value: + chosen_value = real_item + break + self.value = chosen_value + self.set_value(chosen_value) + return False diff --git a/metomi/rose/config_editor/valuewidget/character.py b/metomi/rose/config_editor/valuewidget/character.py new file mode 100644 index 0000000000..5cae01acdc --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/character.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from metomi.rose import META_PROP_TYPE + + +class QuotedTextValueWidget(Gtk.Box): + """This class represents 'character' and 'quoted' types in an entry.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(QuotedTextValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + # Importing here prevents cyclic imports + import metomi.rose.macros.value + + self.type = metadata.get(META_PROP_TYPE) + checker = metomi.rose.macros.value.ValueChecker() + if self.type == "character": + self.type_checker = checker.check_character + self.format_text_in = ( + metomi.rose.config_editor.util.text_for_character_widget + ) + self.format_text_out = ( + metomi.rose.config_editor.util.text_from_character_widget + ) + self.quote_char = "'" + self.esc_quote_chars = "''" + elif self.type == "quoted": + self.type_checker = checker.check_quoted + self.format_text_in = ( + metomi.rose.config_editor.util.text_for_quoted_widget + ) + self.format_text_out = ( + metomi.rose.config_editor.util.text_from_quoted_widget + ) + self.quote_char = '"' + self.esc_quote_chars = '\\"' + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.entry = Gtk.Entry() + self.in_error = not self.type_checker(self.value) + self.set_entry_text() + self.entry.connect( + "button-release-event", self._handle_middle_click_paste + ) + self.entry.connect_after("paste-clipboard", self.setter) + self.entry.connect_after( + "key-release-event", lambda e, v: self.setter(e) + ) + self.entry.connect_after( + "button-release-event", lambda e, v: self.setter(e) + ) + self.entry.show() + self.pack_start(self.entry, expand=True, fill=True, padding=0) + self.entry.connect("focus-in-event", self.hook.trigger_scroll) + self.grab_focus = lambda: self.hook.get_focus(self.entry) + + def set_entry_text(self): + """Initialise the text in the widget.""" + raw_text = self.value + if not self.in_error: + self.entry.set_text(self.format_text_in(raw_text)) + else: + self.entry.set_text(raw_text) + + def setter(self, *args): + var_text = self.entry.get_text() + if not self.value or not self.in_error: + # Text was in processed form + var_text = self.format_text_out(var_text) + if var_text != self.value: + self.value = var_text + self.set_value(var_text) + return False + + def get_focus_index(self): + """Retrieve the current cursor index.""" + position = self.entry.get_position() + if self.in_error: + return position + text = self.entry.get_text() + prefix = text[:position] + i = 0 + while prefix: + if self.value[i] == prefix[0]: + prefix = prefix[1:] + i = i + 1 + if not prefix: + break + return i + + def set_focus_index(self, focus_index): + """Set the current cursor index.""" + self.entry.set_position(focus_index - 1) + + def handle_type_error(self, has_error): + """Handle a change in error related to the value. + + We need to distinguish between quote-related errors and errors + related to pattern matching or other attributes. + + """ + position = self.entry.get_position() + text = self.entry.get_text() + was_in_error = self.in_error + self.in_error = not self.type_checker(self.value) + if self.in_error and not was_in_error: + # This is an incoming quote error. + position += 1 + text[:position].count(self.quote_char) + elif was_in_error and not self.in_error: + # This is an outgoing quote error. + position -= 1 + text[:position].count(self.esc_quote_chars) + else: + # The error isn't related to quotes, so don't do anything. + return False + self.set_entry_text() + self.entry.set_position(position) + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self.setter() + return False diff --git a/metomi/rose/config_editor/valuewidget/choice.py b/metomi/rose/config_editor/valuewidget/choice.py new file mode 100644 index 0000000000..e195f1e0c5 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/choice.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import ast +import shlex + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose.config_editor +import metomi.rose.gtk.choice +import metomi.rose.gtk.dialog +import metomi.rose.opt_parse +import metomi.rose.variable + +from functools import cmp_to_key + + +class ChoicesValueWidget(Gtk.Box): + """This represents a value as actual/available choices. + + Arguments are standard, except for the custom arg_str argument, + set in the metadata. In this case we take a shell command-like + syntax: + + # NAME + # metomi.rose.config_editor.valuewidget.choice.ChoicesValueWidget + # + # SYNOPSIS + # metomi.rose...Widget [OPTIONS] [CUSTOM_CHOICE_HINT ...] + # + # DESCRIPTION + # Represent available choices as a widget. + # + # OPTIONS + # --all-group=CHOICE + # The CHOICE that includes all other choices. + # For example: ALL, STANDARD + # --choices=CHOICE1[,CHOICE2,CHOICE3...] + # Add a comma-delimited list of choice(s) to the list of + # available choices for the widget. + # This option can be used repeatedly. + # --editable + # Allow custom choices to be entered that are not in choices + # --format=FORMAT + # Specify a different format to convert the list of included + # choices into the variable value. + # The only supported format is "python" which outputs the + # result of repr(my_list) - e.g. VARIABLE=["A", "B"]. + # If not specified, the format will default to rose array + # standard e.g. VARIABLE=A, B. + # --guess-groups + # Extrapolate inter-choice dependencies from their names. + # For example, this would guess that "LINUX" would trigger + # "LINUX_QUICK". + # + # CUSTOM_CHOICE_HINT + # Optional custom choice hints for the user, valid with --editable. + """ + + OPTIONS = { + "all_group": [ + ["--all-group"], + {"action": "store", "metavar": "CHOICE"}, + ], + "choices": [ + ["--choices"], + {"action": "append", "default": None, "metavar": "CHOICE"}, + ], + "editable": [ + ["--editable"], + {"action": "store_true", "default": False}, + ], + "format": [["--format"], {"action": "store", "metavar": "FORMAT"}], + "guess_groups": [ + ["--guess-groups"], + {"action": "store_true", "default": False}, + ], + } + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(ChoicesValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + + self.opt_parser = metomi.rose.opt_parse.RoseOptionParser() + self.opt_parser.OPTIONS = self.OPTIONS + self.opt_parser.add_my_options(*list(self.OPTIONS.keys())) + opts, args = self.opt_parser.parse_args(shlex.split(arg_str)) + self.all_group = opts.all_group + self.groups = [] + if opts.choices is not None: + for choices in opts.choices: + self.groups.extend(metomi.rose.variable.array_split(choices)) + self.should_edit = opts.editable + self.value_format = opts.format + self.should_guess_groups = opts.guess_groups + self.hints = list(args) + + self.should_show_kinship = self._calc_should_show_kinship() + list_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + list_vbox.show() + self._listview = metomi.rose.gtk.choice.ChoicesListView( + self._set_value_listview, + self._get_value_values, + self._handle_search, + ) + self._listview.show() + list_frame = Gtk.Frame() + list_frame.show() + list_frame.add(self._listview) + list_vbox.pack_start(list_frame, expand=False, fill=False, padding=0) + self.pack_start(list_vbox, expand=True, fill=True) + tree_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + tree_vbox.show() + self._treeview = metomi.rose.gtk.choice.ChoicesTreeView( + self._set_value_treeview, + self._get_value_values, + self._get_available_values, + self._get_groups, + self._get_is_implicit, + ) + self._treeview.show() + tree_frame = Gtk.Frame() + tree_frame.show() + tree_frame.add(self._treeview) + tree_vbox.pack_start(tree_frame, expand=True, fill=True, padding=0) + if self.should_edit: + add_widget = self._get_add_widget() + tree_vbox.pack_end(add_widget, expand=False, fill=False, padding=0) + self.pack_start(tree_vbox, expand=True, fill=True) + self._listview.connect("focus-in-event", self.hook.trigger_scroll) + self._treeview.connect("focus-in-event", self.hook.trigger_scroll) + self.grab_focus = lambda: self.hook.get_focus(self._listview) + + def _handle_search(self, name): + return False + + def _get_add_widget(self): + add_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + add_entry = Gtk.ComboBoxEntry() + add_entry.connect("changed", self._handle_combo_choice) + add_entry.get_child().connect( + "key-press-event", + lambda w, e: self._handle_text_choice(add_entry, e), + ) + add_entry.set_tooltip_text( + metomi.rose.config_editor.CHOICE_TIP_ENTER_CUSTOM + ) + add_entry.show() + self._set_available_hints(add_entry) + add_hbox.pack_end(add_entry, expand=True, fill=True, padding=0) + add_hbox.show() + return add_hbox + + def _set_available_hints(self, comboboxentry): + model = Gtk.ListStore(str) + values = self._get_value_values() + for hint in self.hints: + if hint not in values: + model.append([hint]) + comboboxentry.set_model(model) + comboboxentry.set_text_column(0) + + def _handle_combo_choice(self, comboboxentry): + iter_ = comboboxentry.get_active_iter() + if iter_ is None: + return False + self._add_custom_choice(comboboxentry, comboboxentry.get_active_text()) + + def _handle_text_choice(self, comboboxentry, event): + if Gdk.keyval_name(event.keyval) in ["Return", "KP_Enter"]: + self._add_custom_choice( + comboboxentry, comboboxentry.get_child().get_text() + ) + return False + + def _add_custom_choice(self, comboboxentry, new_name): + entry = comboboxentry.get_child() + if not new_name: + text = metomi.rose.config_editor.ERROR_BAD_NAME.format("''") + title = metomi.rose.config_editor.DIALOG_TITLE_ERROR + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_ERROR, text, title + ) + return False + new_values = self._get_value_values() + [entry.get_text()] + entry.set_text("") + self._format_and_set_value(" ".join(new_values)) + self._set_available_hints(comboboxentry) + self._listview.refresh() + self._treeview.refresh() + + def _get_value_values(self): + if self.value_format == "python": + try: + values = list(ast.literal_eval(self.value)) + except (SyntaxError, TypeError, ValueError): + values = [] + return values + return metomi.rose.variable.array_split(self.value) + + def _get_available_values(self): + return self.groups + + def _calc_should_show_kinship(self): + """Calculate whether to show parent-child relationships. + + Do not show any if any group has more than one parent group. + + """ + for group in self.groups: + grpset = set(group) + if len([g for g in self.groups if set(g).issubset(grpset)]) > 1: + return False + return True + + def _get_groups(self, name, names): + if self.all_group is not None: + default_groups = [self.all_group] + default_groups = [] + if not self.should_guess_groups or not self.should_show_kinship: + return default_groups + ok_groups = [n for n in names if set(n).issubset(name) and n != name] + ok_groups.sort( + key=cmp_to_key( + lambda x, y: set(x).issubset(y) - set(y).issubset(x) + ) + ) + for group in default_groups: + if group in ok_groups: + ok_groups.remove(group) + return default_groups + ok_groups + + def _get_is_implicit(self, name): + if not self.should_guess_groups: + return False + values = self._get_value_values() + if self.all_group in values: + return True + for group in self.groups: + if group in values and set(group).issubset(name) and group != name: + return True + return False + + def _set_value_listview(self, new_value): + if new_value != self.value: + self._format_and_set_value(new_value) + self._treeview.refresh() + + def _set_value_treeview(self, new_value): + if new_value != self.value: + self._format_and_set_value(new_value) + self._listview.refresh() + + def _format_and_set_value(self, new_value): + if self.value_format == "python": + new_value = repr(shlex.split(new_value)) + else: + new_value = metomi.rose.variable.array_join(shlex.split(new_value)) + self.value = new_value + self.set_value(new_value) diff --git a/metomi/rose/config_editor/valuewidget/combobox.py b/metomi/rose/config_editor/valuewidget/combobox.py new file mode 100644 index 0000000000..f3fac5a597 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/combobox.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor + + +class ComboBoxValueWidget(Gtk.Box): + """This is a class to add a combo box for a set of variable values. + + It needs to have some allowed values set in the variable metadata. + + """ + + FRAC_X_ALIGN = 0.9 + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(ComboBoxValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + comboboxentry = Gtk.ComboBox() + liststore = Gtk.ListStore(str) + cell = Gtk.CellRendererText() + cell.xalign = self.FRAC_X_ALIGN + comboboxentry.pack_start(cell, True) + comboboxentry.add_attribute(cell, "text", 0) + + var_values = self.metadata[metomi.rose.META_PROP_VALUES] + var_titles = self.metadata.get(metomi.rose.META_PROP_VALUE_TITLES) + for k, entry in enumerate(var_values): + if var_titles is not None and var_titles[k]: + liststore.append([var_titles[k] + " (" + entry + ")"]) + else: + liststore.append([entry]) + comboboxentry.set_model(liststore) + if self.value in var_values: + index = self.metadata["values"].index(self.value) + comboboxentry.set_active(index) + comboboxentry.connect("changed", self.setter) + comboboxentry.connect( + "button-press-event", lambda b: comboboxentry.grab_focus() + ) + comboboxentry.show() + self.pack_start(comboboxentry, False, False, 0) + self.grab_focus = lambda: self.hook.get_focus(comboboxentry) + self.set_contains_error = lambda e: comboboxentry.modify_bg( + Gtk.StateType.NORMAL, self.bad_colour + ) + + def setter(self, widget): + index = widget.get_active() + self.value = self.metadata[metomi.rose.META_PROP_VALUES][index] + self.set_value(self.value) + return False diff --git a/metomi/rose/config_editor/valuewidget/files.py b/metomi/rose/config_editor/valuewidget/files.py new file mode 100644 index 0000000000..fc84ce5bc2 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/files.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import os + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor +import metomi.rose.external +import metomi.rose.gtk.util + + +class FileChooserValueWidget(Gtk.Box): + """This class displays a path, with an open dialog to define a new one.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(FileChooserValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.generate_entry() + self.generate_editor_launcher() + self.open_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_OPEN, + size=Gtk.IconSize.MENU, + as_tool=False, + tip_text="Browse for a filename", + ) + self.open_button.show() + self.open_button.connect("clicked", self.run_and_destroy) + self.pack_end(self.open_button, expand=False, fill=False, padding=0) + self.edit_button.set_sensitive(os.path.isfile(self.value)) + + def generate_entry(self): + self.entry = Gtk.Entry() + self.entry.set_text(self.value) + self.entry.show() + self.entry.connect("changed", self.setter) + self.entry.connect("focus-in-event", self.hook.trigger_scroll) + self.pack_start(self.entry, True, True, 0) + self.grab_focus = lambda: self.hook.get_focus(self.entry) + + def run_and_destroy(self, *args): + file_chooser_widget = Gtk.FileChooserDialog( + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ) + ) + if os.path.exists(os.path.dirname(self.value)): + file_chooser_widget.set_filename(self.value) + response = file_chooser_widget.run() + if response in [ + Gtk.ResponseType.ACCEPT, + Gtk.ResponseType.OK, + Gtk.ResponseType.YES, + ]: + self.entry.set_text(file_chooser_widget.get_filename()) + file_chooser_widget.destroy() + return False + + def generate_editor_launcher(self): + self.edit_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_DND, + size=Gtk.IconSize.MENU, + as_tool=False, + tip_text="Edit the file", + ) + self.edit_button.connect( + "clicked", + lambda b: metomi.rose.external.launch_geditor(self.value), + ) + self.pack_end(self.edit_button, expand=False, fill=False, padding=0) + + def setter(self, widget): + self.value = widget.get_text() + self.set_value(self.value) + self.edit_button.set_sensitive(os.path.isfile(self.value)) + return False + + +class FileEditorValueWidget(Gtk.Box): + """This class creates a button that launches an editor for a file path.""" + + FILE_PROTOCOL = "file://{0}" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(FileEditorValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.generate_editor_launcher() + + def generate_editor_launcher(self): + self.edit_button = metomi.rose.gtk.util.CustomButton( + label=metomi.rose.config_editor.LABEL_EDIT, + stock_id=Gtk.STOCK_DND, + size=Gtk.IconSize.MENU, + as_tool=False, + tip_text="Edit the file", + ) + self.edit_button.connect("clicked", self.on_click) + self.pack_start( + self.edit_button, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + + def retrieve_path(self): + root = self.metadata[metomi.rose.config_editor.META_PROP_INTERNAL] + return os.path.join(root, self.value) + + def on_click(self, button): + path = self.retrieve_path() + metomi.rose.external.launch_geditor(path) diff --git a/metomi/rose/config_editor/valuewidget/format.py b/metomi/rose/config_editor/valuewidget/format.py new file mode 100644 index 0000000000..772e65833e --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/format.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config + +from functools import cmp_to_key + + +class FormatsChooserValueWidget(Gtk.Box): + """This class allows the addition of section names to a variable value.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(FormatsChooserValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + + if "values_getter" in self.metadata: + meta = self.metadata + self.values_getter = meta["values_getter"] + else: + self.values_getter = lambda: meta.get("values", []) + num_entries = len(value.split(" ")) + self.entry_table = Gtk.Table(rows=num_entries + 1, columns=1) + self.entry_table.show() + self.entries = [] + for format_name in value.split(): + entry = self.get_entry(format_name) + self.entries.append(entry) + self.add_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.add_box.show() + image = Gtk.Image() + image.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + image.show() + image_event = Gtk.EventBox() + image_event.add(image) + image_event.show() + self.add_box.pack_start( + image_event, expand=False, fill=False, padding=5 + ) + self.data_chooser = Gtk.ComboBoxText() + self.data_chooser.connect( + "focus-in-event", lambda d, e: self.load_data_chooser() + ) + self.data_chooser.connect("changed", lambda d: self.add_new_section()) + self.data_chooser.show() + image_event.connect( + "button-press-event", + lambda i, w: ( + self.load_data_chooser() and self.data_chooser.popup() + ), + ) + self.add_box.pack_start( + self.data_chooser, expand=False, fill=False, padding=0 + ) + + self.load_data_chooser() + self.populate_table() + self.pack_start(self.entry_table, expand=True, fill=True, padding=20) + + def get_entry(self, format_name): + """Create an entry box for a format name.""" + entry = Gtk.Entry() + entry.set_text(format_name) + entry.connect("focus-in-event", self.hook.trigger_scroll) + entry.connect("changed", self.entry_change_handler) + entry.show() + return entry + + def populate_table(self): + """Create a table for the format list and the add widget.""" + self.load_data_chooser() + for child in self.entry_table.get_children(): + self.entry_table.remove(child) + self.entry_table.resize(rows=len(self.entries) + 1, columns=1) + for i, widget in enumerate(self.entries + [self.add_box]): + self.entry_table.attach( + widget, 0, 1, i, i + 1, xoptions=Gtk.AttachOptions.FILL + ) + self.grab_focus = lambda: self.hook.get_focus(self.entries[-1]) + + def add_new_section(self): + value = self.get_active_text(self.data_chooser) + self.data_chooser.set_active(-1) + if value is None: + return False + self.entries.append(self.get_entry(value)) + self.entry_change_handler(self.entries[-1]) + self.populate_table() + return True + + def get_active_text(self, combobox): + index = combobox.get_active() + if index < 0: + return None + return combobox.get_model()[index][0] + + def entry_change_handler(self, entry): + position = entry.get_position() + if entry.get_text() == "" and len(self.entries) > 1: + self.entries.remove(entry) + new_value = " ".join([e.get_text() for e in self.entries]) + self.value = new_value + self.set_value(new_value) + self.populate_table() + self.update_status() + self.load_data_chooser() + self.data_chooser.set_active(-1) + if entry in self.entries and not entry.is_focus(): + entry.grab_focus() + entry.set_position(position) + return False + + def load_data_chooser(self): + data_model = Gtk.ListStore(str) + options = self.values_getter() + options.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + for value in options: + if value not in [e.get_text() for e in self.entries]: + data_model.append([str(value)]) + self.data_chooser.set_model(data_model) + return True diff --git a/metomi/rose/config_editor/valuewidget/intspin.py b/metomi/rose/config_editor/valuewidget/intspin.py new file mode 100644 index 0000000000..a777bf86f0 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/intspin.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import sys + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor + + +class IntSpinButtonValueWidget(Gtk.Box): + """This is a class to represent an integer with a spin button.""" + + WARNING_MESSAGE = "Warning:\n variable value: {0}\n widget value: {1}" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(IntSpinButtonValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.upper = sys.maxsize + self.lower = -sys.maxsize - 1 + + tooltip_text = None + try: + int_value = int(value) + except (TypeError, ValueError): + int_value = 0 + tooltip_text = self.WARNING_MESSAGE.format(value, int_value) + + value_ok = self.lower <= int_value <= self.upper + + if value_ok: + entry = self.make_spinner(int_value) + signal = "changed" + else: + entry = Gtk.Entry() + entry.set_text(self.value) + signal = "activate" + + self.change_id = entry.connect(signal, self.setter) + + entry.set_tooltip_text(tooltip_text) + entry.show() + + self.pack_start(entry, False, False, 0) + + self.warning_img = Gtk.Image() + if not value_ok: + self.warning_img = Gtk.Image() + self.warning_img.set_from_stock( + Gtk.STOCK_DIALOG_WARNING, Gtk.IconSize.MENU + ) + self.warning_img.set_tooltip_text( + metomi.rose.config_editor.WARNING_INTEGER_OUT_OF_BOUNDS + ) + self.warning_img.show() + self.pack_start(self.warning_img, False, False, 0) + + self.grab_focus = lambda: self.hook.get_focus(entry) + + def make_spinner(self, int_value): + my_adj = Gtk.Adjustment( + value=int_value, upper=self.upper, lower=self.lower, step_incr=1 + ) + + spin_button = Gtk.SpinButton(adjustment=my_adj, digits=0) + spin_button.connect("focus-in-event", self.hook.trigger_scroll) + + spin_button.set_numeric(True) + + return spin_button + + def setter(self, widget): + """Callback on widget value change. + + Note: 1. SpinButton's `.get_value_as_int` method is not reliable. It + returns the spin value but not the value of the text that is typed in + manually. 2. Calling `self.set_value` method with a value that cannot + be cast into an `int` may cause `Segmentation fault` on some version of + GTK, so we'll only call `self.set_value` for a value that can be cast + into an in-range `int` value. + """ + text = widget.get_text() + if text != self.value: + self.value = text + try: + value_ok = self.lower <= int(text) <= self.upper + except ValueError: + value_ok = False + if value_ok: + self.set_value(self.value) + self.warning_img.hide() + else: + self.warning_img.show() + return False diff --git a/metomi/rose/config_editor/valuewidget/meta.py b/metomi/rose/config_editor/valuewidget/meta.py new file mode 100644 index 0000000000..b02a490cbe --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/meta.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class MetaValueWidget(Gtk.Box): + """This class generates an entry and button for a metadata flag value.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(MetaValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.entry = Gtk.Entry() + # self.normal_colour = self.entry.style.text[Gtk.StateType.NORMAL] + # self.insens_colour = self.entry.style.text[Gtk.StateType.INSENSITIVE] + self.entry.set_text(self.value) + self.entry.connect( + "button-release-event", self._handle_middle_click_paste + ) + self.entry.connect_after("paste-clipboard", self._check_diff) + self.entry.connect_after("key-release-event", self._check_diff) + self.entry.connect_after("button-release-event", self._check_diff) + self.entry.connect("activate", self._setter) + self.entry.connect("focus-out-event", self._setter) + self.entry.show() + self.button = Gtk.Button(stock=Gtk.STOCK_APPLY) + self.button.connect("clicked", self._setter) + self.button.set_sensitive(False) + self.button.show() + self.pack_start(self.entry, expand=True, fill=True, padding=0) + self.pack_start(self.button, expand=False, fill=False, padding=0) + self.entry.connect("focus-in-event", self.hook.trigger_scroll) + self.grab_focus = lambda: self.hook.get_focus(self.entry) + + def _check_diff(self, *args): + text = self.entry.get_text() + if text == self.value: + # self.entry.modify_text(Gtk.StateType.NORMAL, self.normal_colour) + self.button.set_sensitive(False) + else: + # self.entry.modify_text(Gtk.StateType.NORMAL, self.insens_colour) + self.button.set_sensitive(True) + if not text: + self.button.set_sensitive(False) + + def _setter(self, *args): + text_value = self.entry.get_text() + if text_value and text_value != self.value: + self.value = self.entry.get_text() + self.set_value(self.value) + self._check_diff() + return False + + def get_focus_index(self): + """Return the cursor position within the variable value.""" + return self.entry.get_position() + + def set_focus_index(self, focus_index=None): + if focus_index is None: + return False + self.entry.set_position(focus_index) + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self._check_diff() + return False diff --git a/metomi/rose/config_editor/valuewidget/radiobuttons.py b/metomi/rose/config_editor/valuewidget/radiobuttons.py new file mode 100644 index 0000000000..fc98be03de --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/radiobuttons.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor + + +class RadioButtonsValueWidget(Gtk.Box): + """This is a class to represent a value as radio buttons.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(RadioButtonsValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + + var_values = metadata[metomi.rose.META_PROP_VALUES] + var_titles = metadata.get(metomi.rose.META_PROP_VALUE_TITLES) + + if var_titles: + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.pack_start(vbox, False, True, 0) + vbox.show() + + for k, item in enumerate(var_values): + button_label = str(item) + if var_titles is not None and var_titles[k]: + button_label = var_titles[k] + if k == 0: + radio_button = Gtk.RadioButton( + group=None, label=button_label, use_underline=False + ) + radio_button.real_value = item + else: + radio_button = Gtk.RadioButton( + group=radio_button, label=button_label, use_underline=False + ) + radio_button.real_value = item + if var_titles is not None and var_titles[k]: + radio_button.set_tooltip_text("(" + item + ")") + radio_button.set_active(False) + if item == self.value: + radio_button.set_active(True) + radio_button.connect("toggled", self.setter) + radio_button.connect("button-press-event", self.setter) + radio_button.connect("activate", self.setter) + + if var_titles: + vbox.pack_start(radio_button, False, False, 2) + else: + self.pack_start(radio_button, False, False, 10) + radio_button.show() + radio_button.connect("focus-in-event", self.hook.trigger_scroll) + + self.grab_focus = lambda: self.hook.get_focus(radio_button) + if len(var_values) == 1 and self.value == var_values[0]: + radio_button.set_sensitive(False) + + def setter(self, widget, event=None): + if widget.get_active(): + self.value = widget.real_value + self.set_value(self.value) + return False diff --git a/metomi/rose/config_editor/valuewidget/source.py b/metomi/rose/config_editor/valuewidget/source.py new file mode 100644 index 0000000000..afa0c595c5 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/source.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""This module contains a value widget for the 'source' file setting.""" + +import shlex + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config +import metomi.rose.config_editor +import metomi.rose.formats +import metomi.rose.gtk.choice + +from functools import cmp_to_key + + +class SourceValueWidget(Gtk.Box): + """This class generates a special widget for the file source variable. + + It cheats by passing in a special VariableOperations instance as + arg_str. This is used for search and getting and updating the + available sections. + + """ + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(SourceValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.var_ops = arg_str + formats = [ + f for f in metomi.rose.formats.__dict__ if not f.startswith("__") + ] + self.formats = formats + self.formats_ok = None + self._ok_content_sections = set([None]) + if self.formats_ok is None: + content_sections = self._get_available_sections() + self.formats_ok = bool(content_sections) + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.show() + formats_check_button = Gtk.CheckButton( + metomi.rose.config_editor.FILE_CONTENT_PANEL_FORMAT_LABEL + ) + formats_check_button.set_active(not self.formats_ok) + formats_check_button.connect("toggled", self._toggle_formats) + formats_check_button.show() + formats_check_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + formats_check_hbox.show() + formats_check_hbox.pack_end( + formats_check_button, expand=False, fill=False, padding=0 + ) + vbox.pack_start( + formats_check_hbox, expand=False, fill=False, padding=0 + ) + treeviews_hbox = Gtk.HPaned() + treeviews_hbox.show() + self._listview = metomi.rose.gtk.choice.ChoicesListView( + self._set_listview, + self._get_included_sources, + self._handle_search, + get_custom_menu_items=self._get_custom_menu_items, + ) + self._listview.set_tooltip_text( + metomi.rose.config_editor.FILE_CONTENT_PANEL_TIP + ) + frame = Gtk.Frame() + frame.show() + frame.add(self._listview) + value_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + value_vbox.show() + value_vbox.pack_start(frame, expand=False, fill=False, padding=0) + value_eb = Gtk.EventBox() + value_eb.show() + value_vbox.pack_start(value_eb, expand=True, fill=True, padding=0) + + self._available_frame = Gtk.Frame() + self._generate_available_treeview() + adder_value = "" + adder_metadata = {} + adder_set_value = lambda v: None + adder_hook = metomi.rose.config_editor.valuewidget.ValueWidgetHook() + self._adder = ( + metomi.rose.config_editor.valuewidget.files.FileChooserValueWidget( + adder_value, adder_metadata, adder_set_value, adder_hook + ) + ) + self._adder.entry.connect("activate", self._add_file_source) + self._adder.entry.set_tooltip_text( + metomi.rose.config_editor.TIP_VALUE_ADD_URI + ) + self._adder.show() + treeviews_hbox.add1(value_vbox) + treeviews_hbox.add2(self._available_frame) + vbox.pack_start(treeviews_hbox, expand=True, fill=True, padding=0) + vbox.pack_start(self._adder, expand=True, fill=True, padding=0) + self.grab_focus = lambda: self.hook.get_focus(self._listview) + self.pack_start(vbox, True, True, 0) + + def _toggle_formats(self, widget): + """Toggle the show/hide of the available format sections.""" + self.formats_ok = not widget.get_active() + if widget.get_active(): + self._available_frame.hide() + else: + self._available_frame.show() + + def _generate_available_treeview(self): + """Generate an available choices widget.""" + existing_widget = self._available_frame.get_child() + if existing_widget is not None: + self._available_frame.remove(existing_widget) + self._available_treeview = metomi.rose.gtk.choice.ChoicesTreeView( + self._set_available_treeview, + self._get_included_sources, + self._get_available_sections, + self._get_groups, + title=metomi.rose.config_editor.FILE_CONTENT_PANEL_TITLE, + get_is_included=self._get_section_is_included, + ) + self._available_treeview.set_tooltip_text( + metomi.rose.config_editor.FILE_CONTENT_PANEL_OPT_TIP + ) + self._available_frame.show() + if not self.formats_ok: + self._available_frame.hide() + self._available_frame.add(self._available_treeview) + + def _get_custom_menu_items(self): + """Return some custom menuitems for use in the list view.""" + menuitem_box = Gtk.Box() + menuitem_icon = Gtk.Image.new_from_icon_name( + "dialog-question", Gtk.IconSize.MENU + ) + menuitem_label = Gtk.Label( + label=metomi.rose.config_editor.FILE_CONTENT_PANEL_MENU_OPTIONAL + ) + menuitem = Gtk.MenuItem() + menuitem_box.pack_start(menuitem_icon, False, False, 0) + menuitem_box.pack_start(menuitem_label, False, False, 0) + Gtk.Container.add(menuitem, menuitem_box) + menuitem.connect( + "button-press-event", self._toggle_menu_optional_status + ) + menuitem.show() + return [menuitem] + + def _get_included_sources(self): + """Return sections included in the source variable.""" + return shlex.split(self.value) + + def _get_section_is_included(self, section, included_sections=None): + """Return whether a section is included or not.""" + if included_sections is None: + included_sections = self._get_included_sources() + for i, included_section in enumerate(included_sections): + if included_section.startswith("(") and included_section.endswith( + ")" + ): + included_sections[i] = included_section[1:-1] + return section in included_sections + + def _get_available_sections(self): + """Return sections available to the source variable.""" + ok_content_sections = [] + sections = list(self.var_ops.get_sections(self.metadata["full_ns"])) + for section in sections: + section_has_format = False + for format_ in self.formats: + if section.startswith(format_ + ":"): + section_has_format = True + break + if not section_has_format: + continue + if section.endswith(")"): + section_all = section.rsplit("(", 1)[0] + "(:)" + if section_all not in ok_content_sections: + ok_content_sections.append(section_all) + ok_content_sections.append(section) + ok_content_sections.sort( + key=cmp_to_key(metomi.rose.config.sort_settings) + ) + ok_content_sections.sort(key=cmp_to_key(self._sort_settings_duplicate)) + return ok_content_sections + + def _get_groups(self, name, available_names): + """Return any groups in available_names that supersede name.""" + name_all = name.rsplit("(", 1)[0] + "(:)" + if name_all in available_names and name != name_all: + return [name_all] + return [] + + def _handle_search(self, name): + """Trigger a search for a section.""" + self.var_ops.search_for_var(self.metadata["full_ns"], name) + + def _set_listview(self, new_value): + """React to a set value request from the list view.""" + self._set_value(new_value) + self._available_treeview._realign() + + def _set_available_treeview(self, new_value): + """React to a set value request from the tree view.""" + new_values = shlex.split(new_value) + # Preserve optional values. + old_values = self._get_included_sources() + for i, value in enumerate(new_values): + if "(" + value + ")" in old_values: + new_values[i] = "(" + value + ")" + new_value = " ".join(new_values) + self._set_value(new_value) + self._listview._populate() + + def _add_file_source(self, entry): + """Add a file to the sources list.""" + url = entry.get_text() + if not url: + return False + if self.value: + new_value = self.value + " " + url + else: + new_value = url + self._set_value(new_value) + self._set_available_treeview(new_value) + entry.set_text("") + + def _set_value(self, new_value): + """Set the source variable value.""" + if new_value != self.value: + self.set_value(new_value) + self.value = new_value + + def _sort_settings_duplicate(self, sect1, sect2): + """Sort settings such that xyz(:) appears above xyz(1).""" + sect1_base = sect1.rsplit("(", 1)[0] + sect2_base = sect2.rsplit("(", 1)[0] + if sect1_base != sect2_base: + return 0 + sect1_ind = sect1.replace(sect1_base, "", 1) + sect2_ind = sect2.replace(sect2_base, "", 1) + return (sect2_ind == "(:)") - (sect1_ind == "(:)") + + def _toggle_menu_optional_status(self, menuitem, event): + """Toggle a source's optional status (surrounding brackets or not).""" + iter_ = menuitem._listview_iter + model = menuitem._listview_model + old_section_value = model.get_value(iter_, 0) + if old_section_value.startswith("(") and old_section_value.endswith( + ")" + ): + section_value = old_section_value[1:-1] + else: + section_value = "(" + old_section_value + ")" + model.set_value(iter_, 0, section_value) + values = self._get_included_sources() + for i, value in enumerate(values): + if value == old_section_value: + values[i] = section_value + self._set_value(" ".join(values)) diff --git a/metomi/rose/config_editor/valuewidget/text.py b/metomi/rose/config_editor/valuewidget/text.py new file mode 100644 index 0000000000..4e38f874f0 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/text.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor +import metomi.rose.config_editor.valuewidget +import metomi.rose.env +import metomi.rose.gtk.util + +ENV_COLOUR = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_VAL_ENV +) + + +class RawValueWidget(Gtk.Box): + """This class generates a basic entry widget for an unformatted value.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(RawValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.entry = Gtk.Entry() + if metomi.rose.env.contains_env_var(self.value): + self.entry.modify_text(Gtk.StateType.NORMAL, ENV_COLOUR) + self.entry.set_tooltip_text( + metomi.rose.config_editor.VAR_WIDGET_ENV_INFO + ) + self.entry.set_text(self.value) + self.entry.connect( + "button-release-event", self._handle_middle_click_paste + ) + self.entry.connect_after("paste-clipboard", self.setter) + self.entry.connect_after( + "key-release-event", lambda e, v: self.setter(e) + ) + self.entry.connect_after( + "button-release-event", lambda e, v: self.setter(e) + ) + self.entry.show() + self.pack_start(self.entry, expand=True, fill=True, padding=0) + self.entry.connect("focus-in-event", self.hook.trigger_scroll) + self.grab_focus = lambda: self.hook.get_focus(self.entry) + + def setter(self, widget, *args): + new_value = widget.get_text() + if new_value == self.value: + return False + self.value = new_value + self.set_value(self.value) + if metomi.rose.env.contains_env_var(self.value): + self.entry.modify_text(Gtk.StateType.NORMAL, ENV_COLOUR) + self.entry.set_tooltip_text( + metomi.rose.config_editor.VAR_WIDGET_ENV_INFO + ) + else: + self.entry.set_tooltip_text(None) + return False + + def get_focus_index(self): + """Return the cursor position within the variable value.""" + return self.entry.get_position() + + def set_focus_index(self, focus_index=None): + if focus_index is None: + return False + self.entry.set_position(focus_index) + + def _handle_middle_click_paste(self, widget, event): + if event.button == 2: + self.setter(widget) + return False + + +class TextMultilineValueWidget(Gtk.Box): + """This class displays text with multiple lines.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(TextMultilineValueWidget, self).__init__( + homogeneous=False, spacing=0 + ) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + + self.entrybuffer = Gtk.TextBuffer() + self.entrybuffer.set_text(self.value) + self.entry = Gtk.TextView(buffer=self.entrybuffer) + self.entry.set_wrap_mode(Gtk.WrapMode.WORD) + self.entry.set_left_margin(metomi.rose.config_editor.SPACING_SUB_PAGE) + self.entry.set_right_margin(metomi.rose.config_editor.SPACING_SUB_PAGE) + self.entry.connect("focus-in-event", self.hook.trigger_scroll) + self.entry.show() + + viewport = Gtk.Viewport() + viewport.add(self.entry) + viewport.show() + + self.grab_focus = lambda: self.hook.get_focus(self.entry) + self.entrybuffer.connect("changed", self.setter) + self.pack_start(viewport, expand=True, fill=True, padding=0) + + def get_focus_index(self): + """Return the cursor position within the variable value.""" + mark = self.entrybuffer.get_insert() + iter_ = self.entrybuffer.get_iter_at_mark(mark) + return iter_.get_offset() + + def set_focus_index(self, focus_index=None): + """Set the cursor position within the variable value.""" + if focus_index is None: + return False + iter_ = self.entrybuffer.get_iter_at_offset(focus_index) + self.entrybuffer.place_cursor(iter_) + + def setter(self, widget): + text = widget.get_text(widget.get_start_iter(), widget.get_end_iter()) + if text != self.value: + self.value = text + self.set_value(self.value) + return False diff --git a/metomi/rose/config_editor/valuewidget/valuehints.py b/metomi/rose/config_editor/valuewidget/valuehints.py new file mode 100644 index 0000000000..62991732f1 --- /dev/null +++ b/metomi/rose/config_editor/valuewidget/valuehints.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +from gi.repository import GObject +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor.util +import metomi.rose.gtk.util +import metomi.rose.variable + + +class HintsValueWidget(Gtk.Box): + """This class generates a widget for entering value-hints.""" + + def __init__(self, value, metadata, set_value, hook, arg_str=None): + super(HintsValueWidget, self).__init__(homogeneous=False, spacing=0) + self.value = value + self.metadata = metadata + self.set_value = set_value + self.hook = hook + self.entry = Gtk.Entry() + self.entry.set_text(self.value) + self.entry.connect_after("paste-clipboard", self._setter) + self.entry.connect_after("key-release-event", self._setter) + self.entry.connect_after("button-release-event", self._setter) + self.entry.show() + GObject.idle_add(self._set_completion, self.metadata) + self.pack_start(self.entry, expand=True, fill=True, padding=0) + self.entry.connect("focus-in-event", hook.trigger_scroll) + self.grab_focus = lambda: hook.get_focus(self.entry) + + def _setter(self, *args): + """Alter the variable value and update status.""" + self.value = self.entry.get_text() + self.set_value(self.value) + return False + + def get_focus_index(self): + """Return the cursor position within the variable value.""" + return self.entry.get_position() + + def set_focus_index(self, focus_index=None): + """Set the cursor position within the variable value.""" + if focus_index is None: + return False + self.entry.set_position(focus_index) + + def _set_completion(self, metadata): + """Return a predictive text model for value-hints.""" + completion = Gtk.EntryCompletion() + model = Gtk.ListStore(str) + var_hints = metadata.get(metomi.rose.META_PROP_VALUE_HINTS) + for hint in var_hints: + model.append([hint]) + completion.set_model(model) + completion.set_text_column(0) + completion.set_inline_completion(True) + completion.set_minimum_key_length(0) + self.entry.set_completion(completion) diff --git a/metomi/rose/config_editor/variable.py b/metomi/rose/config_editor/variable.py new file mode 100644 index 0000000000..152fafc44f --- /dev/null +++ b/metomi/rose/config_editor/variable.py @@ -0,0 +1,645 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import copy +import difflib +import re + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config_editor.keywidget +import metomi.rose.config_editor.menuwidget +import metomi.rose.config_editor.valuewidget +import metomi.rose.config_editor.valuewidget.array.row as row +import metomi.rose.config_editor.valuewidget.source +import metomi.rose.config_editor.util +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util +import metomi.rose.reporter +import metomi.rose.resource + + +class VariableWidget(object): + """This class generates a set of widgets representing the variable. + + The set of widgets generated depends on the variable metadata, if any. + Altering values using the widgets will alter the variable object as part + of the internal data model. + + """ + + def __init__( + self, + variable, + var_ops, + is_ghost=False, + show_modes=None, + hide_keywidget_subtext=False, + ): + self.variable = variable + self.key = variable.name + self.value = variable.value + self.meta = variable.metadata + self.is_ghost = is_ghost + self.var_ops = var_ops + if show_modes is None: + show_modes = {} + self.show_modes = show_modes + self.bad_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR + ) + self.hidden_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_IRRELEVANT + ) + self.keywidget = self.get_keywidget(variable, show_modes) + self.generate_valuewidget(variable) + self.is_inconsistent = False + if "type" in variable.error: + self._set_inconsistent(self.valuewidget, variable) + self.errors = list(variable.error.keys()) + self.menuwidget = self.get_menuwidget(variable) + self.generate_labelwidget() + self.generate_contentwidget() + self.yoptions = Gtk.AttachOptions.FILL + self.force_signal_ids = [] + self.is_modified = False + for child_widget in self.get_children(): + setattr(child_widget, "get_parent", lambda: self) + self.trigger_ignored = lambda v, b: b + self.get_parent = lambda: None + self.is_ignored = False + self.set_ignored() + self.update_status() + + def get_keywidget(self, variable, show_modes): + """Creates the keywidget attribute, based on the variable name. + + Loads 'tooltips' or hover-over text based on the variable metadata. + + """ + widget = metomi.rose.config_editor.keywidget.KeyWidget( + variable, + self.var_ops, + self.launch_help, + self.update_status, + show_modes, + ) + widget.show() + return widget + + def generate_labelwidget(self): + """Creates the label widget, a composite of key and menu widgets.""" + self.labelwidget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.labelwidget.show() + self.labelwidget.set_ignored = self.keywidget.set_ignored + menu_offset = ( + self.menuwidget.get_preferred_size().natural_size.height / 2 + ) + key_offset = self.keywidget.get_centre_height() / 2 + menu_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + menu_vbox.pack_start( + self.menuwidget, + expand=False, + fill=False, + padding=max([(key_offset - menu_offset), 0]), + ) + menu_vbox.show() + key_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + key_vbox.pack_start( + self.keywidget, + expand=False, + fill=False, + padding=max([(menu_offset - key_offset) / 2, 0]), + ) + key_vbox.show() + label_content_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label_content_hbox.pack_start( + menu_vbox, expand=False, fill=False, padding=0 + ) + label_content_hbox.pack_start( + key_vbox, expand=False, fill=False, padding=0 + ) + label_content_hbox.show() + event_box = Gtk.EventBox() + event_box.show() + self.labelwidget.pack_start( + label_content_hbox, expand=True, fill=True, padding=0 + ) + self.labelwidget.pack_start( + event_box, expand=True, fill=True, padding=0 + ) + + def generate_contentwidget(self): + """Create the content widget, a vbox-packed valuewidget.""" + self.contentwidget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.contentwidget.show() + content_event_box = Gtk.EventBox() + content_event_box.show() + self.contentwidget.pack_start( + self.valuewidget, expand=False, fill=False, padding=0 + ) + self.contentwidget.pack_start( + content_event_box, expand=True, fill=True, padding=0 + ) + + def _valuewidget_set_value(self, value): + # This is called by a valuewidget to change the variable value. + self.var_ops.set_var_value(self.variable, value) + self.update_status() + + def generate_valuewidget( + self, variable, override_custom=False, use_this_valuewidget=None + ): + """Creates the valuewidget attribute, based on value and metadata.""" + custom_arg = None + if ( + variable.metadata.get("type") + == metomi.rose.config_editor.FILE_TYPE_NORMAL + ): + use_this_valuewidget = ( + metomi.rose.config_editor.valuewidget.source.SourceValueWidget + ) + custom_arg = self.var_ops + set_value = self._valuewidget_set_value + hook_object = metomi.rose.config_editor.valuewidget.ValueWidgetHook( + metomi.rose.config_editor.false_function + ) + metadata = copy.deepcopy(variable.metadata) + if use_this_valuewidget is not None: + self.valuewidget = use_this_valuewidget( + variable.value, + metadata, + set_value, + hook_object, + arg_str=custom_arg, + ) + elif ( + metomi.rose.config_editor.META_PROP_WIDGET in self.meta + and not override_custom + ): + w_val = self.meta[metomi.rose.config_editor.META_PROP_WIDGET] + info = w_val.split(None, 1) + if len(info) > 1: + widget_path, custom_arg = info + else: + widget_path, custom_arg = info[0], None + files = self.var_ops.get_ns_metadata_files(metadata["full_ns"]) + error_handler = lambda e: self.handle_bad_valuewidget( + str(e), variable, set_value + ) + widget = metomi.rose.resource.import_object( + widget_path, files, error_handler + ) + if widget is None: + text = metomi.rose.config_editor.ERROR_IMPORT_CLASS.format( + w_val + ) + self.handle_bad_valuewidget(text, variable, set_value) + try: + self.valuewidget = widget( + variable.value, + metadata, + set_value, + hook_object, + custom_arg, + ) + except Exception as exc: + self.handle_bad_valuewidget(str(exc), variable, set_value) + else: + widget_maker = metomi.rose.config_editor.valuewidget.chooser( + variable.value, variable.metadata, variable.error + ) + self.valuewidget = widget_maker( + variable.value, metadata, set_value, hook_object, custom_arg + ) + for child in self.valuewidget.get_children(): + child.connect("focus-in-event", self.handle_focus_in) + child.connect("focus-out-event", self.handle_focus_out) + if hasattr(child, "get_children"): + for grandchild in child.get_children(): + grandchild.connect("focus-in-event", self.handle_focus_in) + grandchild.connect( + "focus-out-event", self.handle_focus_out + ) + self.valuewidget.show() + + def handle_bad_valuewidget(self, error_info, variable, set_value): + """Handle a bad custom valuewidget import.""" + text = metomi.rose.config_editor.ERROR_IMPORT_WIDGET.format(error_info) + metomi.rose.reporter.Reporter()( + metomi.rose.config_editor.util.ImportWidgetError(text) + ) + self.generate_valuewidget(variable, override_custom=True) + + def handle_focus_in(self, widget, event): + # needs to be the new css way + # widget._first_colour = widget.style.base[Gtk.StateType.NORMAL] + new_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VALUEWIDGET_BASE_SELECTED + ) + widget.modify_base(Gtk.StateType.NORMAL, new_colour) + + def handle_focus_out(self, widget, event): + if hasattr(widget, "_first_colour"): + widget.modify_base(Gtk.StateType.NORMAL, widget._first_colour) + + def get_menuwidget(self, variable, menuclass=None): + """Create the menuwidget attribute, an option menu button.""" + if menuclass is None: + menuclass = metomi.rose.config_editor.menuwidget.MenuWidget + menuwidget = menuclass( + variable, + self.var_ops, + lambda: self.remove_from(self.get_parent()), + self.update_status, + self.launch_help, + ) + menuwidget.show() + return menuwidget + + def insert_into( + self, container, x_info=None, y_info=None, no_menuwidget=False + ): + """Inserts the child widgets of an instance into the 'container'. + + We need arguments specifying where the correct area within the + widget is - in the case of Gtk.Table instances, we need the + number of columns and the row index. These arguments are + generically named x_info and y_info. + + """ + if not hasattr(container, "num_removes"): + setattr(container, "num_removes", 0) + if isinstance(container, Gtk.Table): + row_index = y_info + key_col = 0 + container.attach( + self.labelwidget, + key_col, + key_col + 1, + row_index, + row_index + 1, + xoptions=Gtk.AttachOptions.FILL, + yoptions=Gtk.AttachOptions.FILL, + ) + container.attach( + self.contentwidget, + key_col + 1, + key_col + 2, + row_index, + row_index + 1, + xpadding=5, + xoptions=Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL, + yoptions=self.yoptions, + ) + self.valuewidget.trigger_scroll = lambda b, e: self.force_scroll( + b, container + ) + setattr(self, "get_parent", lambda: container) + elif isinstance( + container, Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + ): + container.pack_start( + self.labelwidget, expand=False, fill=True, padding=5 + ) + container.pack_start( + self.contentwidget, expand=True, fill=True, padding=10 + ) + self.valuewidget.trigger_scroll = lambda b, e: self.force_scroll( + b, container + ) + setattr(self, "get_parent", lambda: container) + + return container + + def force_scroll(self, widget=None, container=None): + """Adjusts a scrolled window to display the correct widget.""" + y_coordinate = None + if widget is not None: + y_coordinate = widget.get_allocation().y + scroll_container = container.get_parent() + if scroll_container is None: + return False + while not isinstance(scroll_container, Gtk.ScrolledWindow): + scroll_container = scroll_container.get_parent() + vadj = scroll_container.get_vadjustment() + if vadj.get_upper() == 1.0 or y_coordinate == -1: + if not self.force_signal_ids: + self.force_signal_ids.append( + vadj.connect_after( + "changed", + lambda a: self.force_scroll(widget, container), + ) + ) + else: + for handler_id in self.force_signal_ids: + vadj.handler_block(handler_id) + self.force_signal_ids = [] + vadj.connect("changed", metomi.rose.config_editor.false_function) + if y_coordinate is None: + vadj.set_upper(vadj.get_upper() + 0.08 * vadj.get_page_size()) + vadj.set_value(vadj.get_upper() - vadj.get_page_size()) + return False + if y_coordinate == -1: # Bad allocation, don't scroll + return False + if ( + not vadj.get_value() + < y_coordinate + < vadj.get_value() + 0.95 * vadj.get_page_size() + ): + vadj.set_value( + min(y_coordinate, vadj.get_upper() - vadj.get_page_size()) + ) + return False + + def remove_from(self, container): + """Removes the child widgets of an instance from the 'container'.""" + container.num_removes += 1 + self.var_ops.remove_var(self.variable) + if isinstance(container, Gtk.Table): + for widget_child in self.get_children(): + for child in container.get_children(): + if child == widget_child: + container.remove(widget_child) + widget_child.destroy() + return container + + def get_children(self): + """Method that returns child widgets - as in some gtk Objects.""" + return [self.labelwidget, self.contentwidget] + + def hide(self): + for widget in self.get_children(): + widget.hide() + + def show(self): + for widget in self.get_children(): + widget.show() + + def set_show_mode(self, show_mode, should_show_mode): + """Sets or unsets special displays for a variable.""" + self.keywidget.set_show_mode(show_mode, should_show_mode) + + def set_ignored(self): + """Sets or unsets a custom ignored state for the widgets.""" + ign_map = self.variable.ignored_reason + self.keywidget.set_ignored() + if ign_map != {}: + # Technically ignored, but could just be ignored by section. + self.is_ignored = True + if "'Ignore'" not in self.menuwidget.option_ui: + self.menuwidget.old_option_ui = self.menuwidget.option_ui + self.menuwidget.old_actions = self.menuwidget.actions + if list(ign_map.keys()) == [ + metomi.rose.variable.IGNORED_BY_SECTION + ]: + # Not ignored in itself, so give Ignore option. + if "'Enable'" in self.menuwidget.option_ui: + self.menuwidget.option_ui = re.sub( + "", + r"", + self.menuwidget.option_ui, + ) + else: + # Ignored in itself, so needs Enable option. + self.menuwidget.option_ui = re.sub( + "", + r"", + self.menuwidget.option_ui, + ) + self.update_status() + self.set_sensitive(False) + else: + # Enabled. + self.is_ignored = False + if "'Enable'" in self.menuwidget.option_ui: + self.menuwidget.option_ui = re.sub( + "", + r"", + self.menuwidget.option_ui, + ) + self.update_status() + if not self.is_ghost: + self.set_sensitive(True) + + def update_status(self): + """Handles variable modified status.""" + self.set_modified(self.var_ops.is_var_modified(self.variable)) + self.keywidget.update_comment_display() + + def set_modified(self, is_modified=True): + """Applies or unsets a custom 'modified' state for the widgets.""" + if is_modified == self.is_modified: + return False + self.is_modified = is_modified + self.keywidget.set_modified(is_modified) + if not is_modified and isinstance(self.keywidget.entry, Gtk.Entry): + # This variable should now be displayed as a normal variable. + self.valuewidget.trigger_refresh(self.variable.metadata["id"]) + + def set_sensitive(self, is_sensitive=True): + """Sets whether the widgets are grayed-out or 'insensitive'.""" + for widget in [self.keywidget, self.valuewidget]: + widget.set_sensitive(is_sensitive) + return False + + def grab_focus( + self, focus_container=None, scroll_bottom=False, index=None + ): + """Method similar to Gtk.Widget - get the keyboard focus.""" + if hasattr(self, "valuewidget"): + self.valuewidget.grab_focus() + if index is not None and hasattr( + self.valuewidget, "set_focus_index" + ): + self.valuewidget.set_focus_index(index) + for child in self.valuewidget.get_children(): + if ( + self.valuewidget.get_sensitive() & child.get_state_flags() + and self.valuewidget.get_parent().get_sensitive() + & child.get_state_flags() + ): + break + else: # no break + if hasattr(self, "menuwidget"): + self.menuwidget.get_children()[0].grab_focus() + if scroll_bottom and focus_container is not None: + self.force_scroll(None, container=focus_container) + if hasattr(self, "keywidget") and self.key == "": + self.keywidget.grab_focus() + return False + + def get_focus_index(self): + """Get the current cursor position in the variable value string.""" + if hasattr(self, "valuewidget") and hasattr( + self.valuewidget, "get_focus_index" + ): + return self.valuewidget.get_focus_index() + diff = difflib.SequenceMatcher( + None, self.variable.old_value, self.variable.value + ) + # Return all end-of-block indicies for changed blocks + indicies = [x[4] for x in diff.get_opcodes() if x[0] != "equal"] + if not indicies: + return None + return indicies[-1] + + def launch_help(self, url_mode=False): + """Launch a help dialog or a URL in a web browser.""" + if url_mode: + return self.var_ops.launch_url(self.variable) + if metomi.rose.META_PROP_HELP not in self.meta: + return + help_text = None + if self.show_modes.get( + metomi.rose.config_editor.SHOW_MODE_CUSTOM_HELP + ): + format_string = metomi.rose.config_editor.CUSTOM_FORMAT_HELP + help_text = metomi.rose.variable.expand_format_string( + format_string, self.variable + ) + if help_text is None: + help_text = self.meta[metomi.rose.META_PROP_HELP] + self._launch_help_dialog(help_text) + + def _launch_help_dialog(self, help_text): + """Launch a scrollable dialog for this variable's help text.""" + title = metomi.rose.config_editor.DIALOG_HELP_TITLE.format( + self.variable.metadata["id"] + ) + ns = self.variable.metadata["full_ns"] + search_function = lambda i: self.var_ops.search_for_var(ns, i) + metomi.rose.gtk.dialog.run_hyperlink_dialog( + Gtk.STOCK_DIALOG_INFO, help_text, title, search_function + ) + return False + + def _set_inconsistent(self, valuewidget, variable): + valuewidget.modify_base(Gtk.StateType.NORMAL, self.bad_colour) + self.is_inconsistent = True + widget_list = valuewidget.get_children() + while widget_list: + widget = widget_list.pop() + widget.modify_text(Gtk.StateType.NORMAL, self.bad_colour) + if hasattr(widget, "set_inconsistent"): + widget.set_inconsistent(True) + if isinstance(widget, Gtk.RadioButton): + widget.set_active(False) + if hasattr(widget, "get_group") and hasattr( + widget.get_group(), "set_inconsistent" + ): + widget.get_group().set_inconsistent(True) + if isinstance(widget, Gtk.Entry): + widget.modify_fg(Gtk.StateType.NORMAL, self.bad_colour) + if isinstance(widget, Gtk.SpinButton): + try: + v_value = float(variable.value) + w_value = float(widget.get_value()) + except (TypeError, ValueError): + widget.modify_text( + Gtk.StateType.NORMAL, self.hidden_colour + ) + else: + if w_value != v_value: + widget.modify_text( + Gtk.StateType.NORMAL, self.hidden_colour + ) + if hasattr(widget, "get_children"): + widget_list.extend(widget.get_children()) + elif hasattr(widget, "get_child"): + widget_list.append(widget.get_child()) + + def _set_consistent(self, valuewidget, variable): + normal_style = Gtk.Style() + normal_base = normal_style.base[Gtk.StateType.NORMAL] + normal_fg = normal_style.fg[Gtk.StateType.NORMAL] + normal_text = normal_style.text[Gtk.StateType.NORMAL] + valuewidget.modify_base(Gtk.StateType.NORMAL, normal_base) + self.is_inconsistent = True + for widget in valuewidget.get_children(): + widget.modify_text(Gtk.StateType.NORMAL, normal_text) + if hasattr(widget, "set_inconsistent"): + widget.set_inconsistent(False) + if isinstance(widget, Gtk.Entry): + widget.modify_fg(Gtk.StateType.NORMAL, normal_fg) + if hasattr(widget, "get_group") and hasattr( + widget.get_group(), "set_inconsistent" + ): + widget.get_group().set_inconsistent(False) + + def _get_focus(self, widget_for_focus): + widget_for_focus.grab_focus() + self.valuewidget.trigger_scroll(widget_for_focus, None) + if isinstance(widget_for_focus, Gtk.Entry): + widget_for_focus.grab_focus_without_selecting() + text_length = len(widget_for_focus.get_text()) + if text_length > 0: + widget_for_focus.set_position(text_length) + widget_for_focus.select_region(text_length, text_length) + return False + + def needs_type_error_refresh(self): + """Check if self needs to be re-created on 'type' error.""" + if hasattr(self.valuewidget, "handle_type_error"): + return False + return True + + def type_error_refresh(self, variable): + """Handle a type error.""" + if metomi.rose.META_PROP_TYPE in variable.error: + self._set_inconsistent(self.valuewidget, variable) + else: + self._set_consistent(self.valuewidget, variable) + self.variable = variable + self.errors = list(variable.error.keys()) + self.valuewidget.handle_type_error( + metomi.rose.META_PROP_TYPE in self.errors + ) + self.menuwidget.refresh(variable) + self.keywidget.refresh(variable) + + +class RowVariableWidget(VariableWidget): + """This class generates a set of widgets for use as a row in a table.""" + + def __init__(self, *args, **kwargs): + self.length = kwargs.pop("length") + super(RowVariableWidget, self).__init__(*args, **kwargs) + + def generate_valuewidget(self, variable, override_custom=False): + """Creates the valuewidget attribute, based on value and metadata.""" + if metomi.rose.META_PROP_LENGTH in variable.metadata or isinstance( + variable.metadata.get(metomi.rose.META_PROP_TYPE), list + ): + use_this_valuewidget = self.make_row_valuewidget + else: + use_this_valuewidget = None + super(RowVariableWidget, self).generate_valuewidget( + variable, + override_custom=override_custom, + use_this_valuewidget=use_this_valuewidget, + ) + + def make_row_valuewidget(self, *args, **kwargs): + kwargs.update({"arg_str": str(self.length)}) + return row.RowArrayValueWidget(*args, **kwargs) diff --git a/metomi/rose/config_editor/window.py b/metomi/rose/config_editor/window.py new file mode 100644 index 0000000000..a80cf1112a --- /dev/null +++ b/metomi/rose/config_editor/window.py @@ -0,0 +1,974 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import os +import re +import webbrowser + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.config +import metomi.rose.gtk.dialog +import metomi.rose.gtk.util +import metomi.rose.resource + +from functools import cmp_to_key + + +REC_SPLIT_MACRO_TEXT = re.compile( + "(.{" + + str(metomi.rose.config_editor.DIALOG_BODY_MACRO_CHANGES_MAX_LENGTH) + + "})" +) + + +class MetadataTable(object): + """ + Creates a table from the provided list of paths appending it to the + provided parent. + The current state of the table can be obtained using '.paths'. + """ + + def __init__(self, paths, parent): + self.paths = paths + self.parent = parent + self.previous = None + self.draw_table() + + def draw_table(self): + """Draws the table.""" + # destroy previous table if present + if self.previous: + self.previous.destroy() + + # rows, cols + table = Gtk.Table(len(self.paths), 2) + + # table rows + for i, path in enumerate(self.paths): + label = Gtk.Label(label=path) + label.set_alignment(xalign=0.0, yalign=0.5) + # component, col_from, col_to, row_from, row_to + table.attach( + label, + 0, + 1, + i, + i + 1, + xoptions=Gtk.AttachOptions.FILL, + xpadding=15, + ) + label.show() + button = Gtk.Button("Remove") + button.data = path + # component, col_from, col_to, row_from, row_to + table.attach(button, 1, 2, i, i + 1) + button.connect("clicked", self.remove_row) + button.show() + + # append table + self.parent.pack_start(table, True, True, 0) + self.parent.reorder_child(table, 2) + table.show() + + self.previous = table + + def remove_row(self, widget): + """To be called upon 'remove' button press.""" + self.paths.remove(widget.data) + self.draw_table() + + def add_row(self, path): + """Creates a new table row from the provided path (as a string).""" + self.paths.append(path) + self.draw_table() + + +class MainWindow(object): + """Generate the main window and dialog handling for this example.""" + + def load( + self, + name="Untitled", + menu=None, + accelerators=None, + toolbar=None, + nav_panel=None, + status_bar=None, + notebook=None, + page_change_func=metomi.rose.config_editor.false_function, + save_func=metomi.rose.config_editor.false_function, + ): + self.window = Gtk.Window() + self.window.set_title( + name + " - " + metomi.rose.config_editor.LAUNCH_COMMAND + ) + self.util = metomi.rose.config_editor.util.Lookup() + self.window.set_icon(metomi.rose.gtk.util.get_icon()) + Gtk.Window.set_default_icon_list([self.window.get_icon()]) + self.window.set_default_size(*metomi.rose.config_editor.SIZE_WINDOW) + self.window.set_destroy_with_parent(False) + self.save_func = save_func + self.top_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.log_window = None # The stack viewer. + self.window.add(self.top_vbox) + # Load the menu bar + if menu is not None: + menu.show() + self.top_vbox.pack_start(menu, False, True, 0) + if accelerators is not None: + self.window.add_accel_group(accelerators) + if toolbar is not None: + toolbar.show() + self.top_vbox.pack_start(toolbar, False, True, 0) + # Load the nav_panel and notebook + for signal in [ + "switch-page", + "focus-tab", + "select-page", + "change-current-page", + ]: + notebook.connect_after(signal, page_change_func) + self.generate_main_hbox(nav_panel, notebook) + self.top_vbox.pack_start(self.main_hbox, True, True, 0) + self.top_vbox.pack_start( + status_bar, expand=False, fill=False, padding=0 + ) + self.top_vbox.show() + self.window.show() + nav_panel.tree.columns_autosize() + nav_panel.grab_focus() + + def generate_main_hbox(self, nav_panel, notebook): + """Create the main container of the GUI window. + + This contains the tree panel and notebook. + + """ + self.main_hbox = Gtk.HPaned() + self.main_hbox.pack1(nav_panel, resize=False, shrink=False) + self.main_hbox.show() + self.main_hbox.pack2(notebook, resize=True, shrink=True) + self.main_hbox.show() + self.main_hbox.set_position(metomi.rose.config_editor.WIDTH_TREE_PANEL) + + def launch_about_dialog(self, somewidget=None): + """Create a dialog showing the 'About' information.""" + metomi.rose.gtk.dialog.run_about_dialog( + name=metomi.rose.config_editor.PROGRAM_NAME, + copyright_=metomi.rose.config_editor.COPYRIGHT, + logo_path="etc/images/rose-logo.png", + website=metomi.rose.config_editor.PROJECT_URL, + website_label=metomi.rose.config_editor.PROJECT_URL, + ) + + def _reload_choices(self, liststore, top_name, add_choices): + liststore.clear() + for full_section_id in add_choices: + section_top_name, section_id = full_section_id.split(":", 1) + if section_top_name == top_name: + liststore.append([section_id]) + + def launch_add_dialog(self, names, add_choices, section_help): + """Launch a dialog asking for a section name.""" + add_dialog = Gtk.Dialog( + title=metomi.rose.config_editor.DIALOG_TITLE_ADD, + parent=self.window, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ), + ) + ok_button = add_dialog.action_area.get_children()[0] + config_label = Gtk.Label( + label=metomi.rose.config_editor.DIALOG_BODY_ADD_CONFIG + ) + config_label.show() + label = Gtk.Label( + label=metomi.rose.config_editor.DIALOG_BODY_ADD_SECTION + ) + label.show() + config_name_box = Gtk.ComboBoxText() + for name in names: + config_name_box.append_text(name.lstrip("/")) + config_name_box.show() + config_name_box.set_active(0) + section_box = Gtk.Entry() + if section_help is not None: + section_box.set_text(section_help) + section_completion = Gtk.EntryCompletion() + liststore = Gtk.ListStore(str) + section_completion.set_model(liststore) + section_box.set_completion(section_completion) + section_completion.set_text_column(0) + self._reload_choices(liststore, names[0], add_choices) + section_box.show() + config_name_box.connect( + "changed", + lambda c: self._reload_choices( + liststore, names[c.get_active()], add_choices + ), + ) + section_box.connect( + "activate", lambda s: add_dialog.response(Gtk.ResponseType.OK) + ) + section_box.connect( + "changed", lambda s: ok_button.set_sensitive(bool(s.get_text())) + ) + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + vbox.pack_start(config_label, expand=False, fill=False, padding=5) + vbox.pack_start(config_name_box, expand=False, fill=False, padding=5) + vbox.pack_start(label, expand=False, fill=False, padding=5) + vbox.pack_start(section_box, expand=False, fill=False, padding=5) + vbox.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + hbox.pack_start(vbox, expand=True, fill=True, padding=10) + hbox.show() + add_dialog.vbox.pack_start(hbox, True, True, 0) + section_box.grab_focus() + section_box.set_position(-1) + section_completion.complete() + ok_button.set_sensitive(bool(section_box.get_text())) + response = add_dialog.run() + if response in [ + Gtk.ResponseType.OK, + Gtk.ResponseType.YES, + Gtk.ResponseType.ACCEPT, + ]: + config_name_entered = names[config_name_box.get_active()] + section_name_entered = section_box.get_text() + add_dialog.destroy() + return config_name_entered, section_name_entered + add_dialog.destroy() + return None, None + + def launch_exit_warning_dialog(self): + """Launch a 'really want to quit' dialog.""" + text = "Save changes before closing?" + exit_dialog = Gtk.MessageDialog( + buttons=Gtk.ButtonsType.NONE, + message_format=text, + parent=self.window, + ) + exit_dialog.add_buttons( + Gtk.STOCK_NO, + Gtk.ResponseType.REJECT, + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CLOSE, + Gtk.STOCK_YES, + Gtk.ResponseType.ACCEPT, + ) + exit_dialog.set_title( + metomi.rose.config_editor.DIALOG_TITLE_SAVE_CHANGES + ) + exit_dialog.set_modal(True) + exit_dialog.set_keep_above(True) + exit_dialog.action_area.get_children()[1].grab_focus() + response = exit_dialog.run() + exit_dialog.destroy() + if response == Gtk.ResponseType.REJECT: + Gtk.main_quit() + elif response == Gtk.ResponseType.ACCEPT: + save_ok = self.save_func() + if save_ok: + Gtk.main_quit() + return False + + def launch_graph_dialog(self, name_section_dict): + """Launch a dialog asking for a config and section to graph. + + name_section_dict is a dictionary containing config names + as keys, and lists of available sections as values. + + """ + prefs = {} + return self._launch_choose_section_dialog( + name_section_dict, + prefs, + metomi.rose.config_editor.DIALOG_TITLE_GRAPH, + metomi.rose.config_editor.DIALOG_BODY_GRAPH_CONFIG, + metomi.rose.config_editor.DIALOG_BODY_GRAPH_SECTION, + null_section_choice=True, + ) + + def launch_help_dialog(self, somewidget=None): + """Launch a browser to open the help url.""" + webbrowser.open( + "https://metomi.github.io/rose/doc/html/index.html", + new=True, + autoraise=True, + ) + return False + + def launch_ignore_dialog(self, name_section_dict, prefs, is_ignored): + """Launch a dialog asking for a section name to ignore or enable. + + name_section_dict is a dictionary containing config names + as keys, and lists of available sections as values. + prefs is in the same format, but indicates preferred values. + is_ignored is a bool that controls whether this is an ignore + section dialog or an enable section dialog. + + """ + if is_ignored: + dialog_title = metomi.rose.config_editor.DIALOG_TITLE_IGNORE + else: + dialog_title = metomi.rose.config_editor.DIALOG_TITLE_ENABLE + config_title = ( + metomi.rose.config_editor.DIALOG_BODY_IGNORE_ENABLE_CONFIG + ) + if is_ignored: + section_title = ( + metomi.rose.config_editor.DIALOG_BODY_IGNORE_SECTION + ) + else: + section_title = ( + metomi.rose.config_editor.DIALOG_BODY_ENABLE_SECTION + ) + return self._launch_choose_section_dialog( + name_section_dict, prefs, dialog_title, config_title, section_title + ) + + def _launch_choose_section_dialog( + self, + name_section_dict, + prefs, + dialog_title, + config_title, + section_title, + null_section_choice=False, + do_target_section=False, + ): + chooser_dialog = Gtk.Dialog( + title=dialog_title, + parent=self.window, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ), + ) + config_label = Gtk.Label(label=config_title) + config_label.show() + section_label = Gtk.Label(label=section_title) + section_label.show() + config_name_box = Gtk.ComboBoxText() + name_keys = sorted(list(name_section_dict.keys())) + for k, name in enumerate(name_keys): + config_name_box.append_text(name) + if name in prefs: + config_name_box.set_active(k) + if config_name_box.get_active() == -1: + config_name_box.set_active(0) + config_name_box.show() + section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + section_box.show() + null_section_checkbutton = Gtk.CheckButton( + metomi.rose.config_editor.DIALOG_LABEL_NULL_SECTION + ) + null_section_checkbutton.connect( + "toggled", lambda b: section_box.set_sensitive(not b.get_active()) + ) + if null_section_choice: + null_section_checkbutton.show() + null_section_checkbutton.set_active(True) + index = config_name_box.get_active() + section_combo = self._reload_section_choices( + section_box, + name_section_dict[name_keys[index]], + prefs.get(name_keys[index], []), + ) + config_name_box.connect( + "changed", + lambda c: self._reload_section_choices( + section_box, + name_section_dict[name_keys[c.get_active()]], + prefs.get(name_keys[c.get_active()], []), + ), + ) + vbox = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=metomi.rose.config_editor.SPACING_PAGE, + ) + vbox.pack_start(config_label, expand=False, fill=False, padding=0) + vbox.pack_start(config_name_box, expand=False, fill=False, padding=0) + vbox.pack_start(section_label, expand=False, fill=False, padding=0) + vbox.pack_start( + null_section_checkbutton, expand=False, fill=False, padding=0 + ) + vbox.pack_start(section_box, expand=False, fill=False, padding=0) + if do_target_section: + target_section_entry = Gtk.Entry() + self._reload_target_section_entry( + section_combo, + target_section_entry, + name_keys[config_name_box.get_active()], + name_section_dict, + ) + section_combo.connect( + "changed", + lambda combo: self._reload_target_section_entry( + combo, + target_section_entry, + name_keys[config_name_box.get_active()], + name_section_dict, + ), + ) + target_section_entry.show() + vbox.pack_start( + target_section_entry, expand=False, fill=False, padding=0 + ) + vbox.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start( + vbox, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + hbox.show() + chooser_dialog.vbox.pack_start( + hbox, True, True, metomi.rose.config_editor.SPACING_PAGE + ) + section_box.grab_focus() + response = chooser_dialog.run() + if response in [ + Gtk.ResponseType.OK, + Gtk.ResponseType.YES, + Gtk.ResponseType.ACCEPT, + ]: + config_name_entered = name_keys[config_name_box.get_active()] + if null_section_checkbutton.get_active(): + chooser_dialog.destroy() + if do_target_section: + return config_name_entered, None, None + return config_name_entered, None + + for widget in section_box.get_children(): + if hasattr(widget, "get_active"): + index = widget.get_active() + sections = name_section_dict[config_name_entered] + section_name = sections[index] + if do_target_section: + target_section_name = target_section_entry.get_text() + chooser_dialog.destroy() + if do_target_section: + return ( + config_name_entered, + section_name, + target_section_name, + ) + return config_name_entered, section_name + chooser_dialog.destroy() + if do_target_section: + return None, None, None + return None, None + + def _reload_section_choices(self, vbox, sections, prefs): + for child in vbox.get_children(): + vbox.remove(child) + sections.sort(key=cmp_to_key(metomi.rose.config.sort_settings)) + section_chooser = Gtk.ComboBoxText() + for k, section in enumerate(sections): + section_chooser.append_text(section) + if section in prefs: + section_chooser.set_active(k) + if section_chooser.get_active() == -1 and sections: + section_chooser.set_active(0) + section_chooser.show() + vbox.pack_start(section_chooser, expand=False, fill=False, padding=0) + return section_chooser + + def _reload_target_section_entry( + self, + section_combo_box, + target_entry, + config_name_entered, + name_section_dict, + ): + index = section_combo_box.get_active() + sections = name_section_dict[config_name_entered] + section_name = sections[index] + target_entry.set_text(section_name) + + def launch_macro_changes_dialog( + self, + config_name, + macro_name, + changes_list, + mode="transform", + search_func=metomi.rose.config_editor.false_function, + ): + """Launch a dialog explaining macro changes.""" + dialog = MacroChangesDialog( + self.window, config_name, macro_name, mode, search_func + ) + return dialog.display(changes_list) + + def launch_new_config_dialog(self, root_directory): + """Launch a dialog allowing naming of a new configuration.""" + existing_apps = os.listdir(root_directory) + checker_function = lambda t: t not in existing_apps + label = metomi.rose.config_editor.DIALOG_LABEL_CONFIG_CHOOSE_NAME + ok_tip_text = metomi.rose.config_editor.TIP_CONFIG_CHOOSE_NAME + err_tip_text = metomi.rose.config_editor.TIP_CONFIG_CHOOSE_NAME_ERROR + dialog, container, name_entry = ( + metomi.rose.gtk.dialog.get_naming_dialog( + label, checker_function, ok_tip_text, err_tip_text + ) + ) + dialog.set_title(metomi.rose.config_editor.DIALOG_TITLE_CONFIG_CREATE) + meta_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + meta_label = Gtk.Label( + label=metomi.rose.config_editor.DIALOG_LABEL_CONFIG_CHOOSE_META + ) + meta_label.show() + meta_entry = Gtk.Entry() + tip_text = metomi.rose.config_editor.TIP_CONFIG_CHOOSE_META + meta_entry.set_tooltip_text(tip_text) + meta_entry.connect( + "activate", lambda b: dialog.response(Gtk.ResponseType.ACCEPT) + ) + meta_entry.show() + meta_hbox.pack_start( + meta_label, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + meta_hbox.pack_start( + meta_entry, + expand=False, + fill=True, + padding=metomi.rose.config_editor.SPACING_SUB_PAGE, + ) + meta_hbox.show() + container.pack_start( + meta_hbox, + expand=False, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + response = dialog.run() + name = None + meta = None + if name_entry.get_text(): + name = name_entry.get_text().strip().strip("/") + if meta_entry.get_text(): + meta = meta_entry.get_text().strip() + dialog.destroy() + if response == Gtk.ResponseType.ACCEPT: + return name, meta + return None, None + + def launch_open_dirname_dialog(self): + """Launch a FileChooserDialog and return a directory, or None.""" + open_dialog = Gtk.FileChooserDialog( + title=metomi.rose.config_editor.DIALOG_TITLE_OPEN, + action=Gtk.FileChooserAction.OPEN, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, + Gtk.ResponseType.OK, + ), + ) + open_dialog.set_transient_for(self.window) + open_dialog.set_icon(self.window.get_icon()) + open_dialog.set_default_response(Gtk.ResponseType.OK) + config_filter = Gtk.FileFilter() + config_filter.add_pattern(metomi.rose.TOP_CONFIG_NAME) + config_filter.add_pattern(metomi.rose.SUB_CONFIG_NAME) + config_filter.add_pattern(metomi.rose.INFO_CONFIG_NAME) + open_dialog.set_filter(config_filter) + response = open_dialog.run() + if response in [ + Gtk.ResponseType.OK, + Gtk.ResponseType.ACCEPT, + Gtk.ResponseType.YES, + ]: + config_directory = os.path.dirname(open_dialog.get_filename()) + open_dialog.destroy() + return config_directory + open_dialog.destroy() + return None + + def launch_load_metadata_dialog(self): + """Launches a dialoge for selecting a metadata path.""" + open_dialog = Gtk.FileChooserDialog( + title=metomi.rose.config_editor.DIALOG_TITLE_LOAD_METADATA, + action=Gtk.FileChooserAction.SELECT_FOLDER, + buttons=( + Gtk.STOCK_CLOSE, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_ADD, + Gtk.ResponseType.OK, + ), + ) + open_dialog.set_transient_for(self.window) + open_dialog.set_icon(self.window.get_icon()) + open_dialog.set_default_response(Gtk.ResponseType.OK) + response = open_dialog.run() + if response in [ + Gtk.ResponseType.OK, + Gtk.ResponseType.ACCEPT, + Gtk.ResponseType.YES, + ]: + config_directory = open_dialog.get_filename() + open_dialog.destroy() + return config_directory + open_dialog.destroy() + return None + + def launch_metadata_manager(self, paths): + """ + Launches a dialogue where users may add or remove custom meta data + paths. + """ + dialog = Gtk.Dialog( + title=metomi.rose.config_editor.DIALOG_TITLE_MANAGE_METADATA, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, + Gtk.ResponseType.OK, + ), + ) + + # add description + label = Gtk.Label( + label="Specify metadata paths to override the default " + "metadata.\n" + ) + dialog.vbox.pack_start(label, True, True, 0) + label.show() + + # create table of paths + table = MetadataTable(paths, dialog.vbox) + + # create add path button + button = Gtk.Button("Add Path") + + def add_path(): + _path = self.launch_load_metadata_dialog() + if _path: + table.add_row(_path) + + button.connect("clicked", lambda b: add_path()) + dialog.vbox.pack_start(button, True, True, 0) + button.show() + + # open the dialogue + response = dialog.run() + if response in [ + Gtk.ResponseType.OK, + Gtk.ResponseType.ACCEPT, + Gtk.ResponseType.YES, + ]: + # if user clicked 'ok' + dialog.destroy() + return table.paths + else: + dialog.destroy() + return None + + def launch_prefs(self, somewidget=None): + """Launch a dialog explaining preferences.""" + text = metomi.rose.config_editor.DIALOG_LABEL_PREFERENCES + title = metomi.rose.config_editor.DIALOG_TITLE_PREFERENCES + metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_INFO, text, title + ) + return False + + def launch_remove_dialog(self, name_section_dict, prefs): + """Launch a dialog asking for a section name to remove. + + name_section_dict is a dictionary containing config names + as keys, and lists of available sections as values. + prefs is in the same format, but indicates preferred values. + + """ + return self._launch_choose_section_dialog( + name_section_dict, + prefs, + metomi.rose.config_editor.DIALOG_TITLE_REMOVE, + metomi.rose.config_editor.DIALOG_BODY_REMOVE_CONFIG, + metomi.rose.config_editor.DIALOG_BODY_REMOVE_SECTION, + ) + + def launch_rename_dialog(self, name_section_dict, prefs): + """Launch a dialog asking for a section name to rename. + + name_section_dict is a dictionary containing config names + as keys, and lists of available sections as values. + prefs is in the same format, but indicates preferred values. + + """ + return self._launch_choose_section_dialog( + name_section_dict, + prefs, + metomi.rose.config_editor.DIALOG_TITLE_RENAME, + metomi.rose.config_editor.DIALOG_BODY_RENAME_CONFIG, + metomi.rose.config_editor.DIALOG_BODY_RENAME_SECTION, + do_target_section=True, + ) + + def launch_view_stack(self, undo_stack, redo_stack, undo_func): + """Load a view of the stack.""" + self.log_window = metomi.rose.config_editor.stack.StackViewer( + undo_stack, redo_stack, undo_func + ) + self.log_window.set_transient_for(self.window) + + +class MacroChangesDialog(Gtk.Dialog): + """Class to hold a dialog summarising macro results.""" + + COLUMNS = ["Section", "Option", "Type", "Value", "Info"] + MODE_COLOURS = { + "transform": metomi.rose.config_editor.COLOUR_MACRO_CHANGED, + "validate": metomi.rose.config_editor.COLOUR_MACRO_ERROR, + "warn": metomi.rose.config_editor.COLOUR_MACRO_WARNING, + } + MODE_TEXT = { + "transform": metomi.rose.config_editor.DIALOG_TEXT_MACRO_CHANGED, + "validate": metomi.rose.config_editor.DIALOG_TEXT_MACRO_ERROR, + "warn": metomi.rose.config_editor.DIALOG_TEXT_MACRO_WARNING, + } + + def __init__(self, window, config_name, macro_name, mode, search_func): + self.util = metomi.rose.config_editor.util.Lookup() + self.short_config_name = config_name.rstrip("/").split("/")[-1] + self.top_config_name = config_name.lstrip("/").split("/")[0] + self.short_macro_name = macro_name.split(".")[-1] + self.for_transform = mode == "transform" + self.for_validate = mode == "validate" + self.macro_name = macro_name + self.mode = mode + self.search_func = search_func + if self.for_validate: + title = metomi.rose.config_editor.DIALOG_TITLE_MACRO_VALIDATE + button_list = [Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT] + else: + title = metomi.rose.config_editor.DIALOG_TITLE_MACRO_TRANSFORM + button_list = [ + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_APPLY, + Gtk.ResponseType.ACCEPT, + ] + title = title.format(self.short_macro_name, self.short_config_name) + button_list = tuple(button_list) + super(MacroChangesDialog, self).__init__( + buttons=button_list, parent=window + ) + if not self.for_transform: + self.set_modal(False) + self.set_title(title.format(macro_name)) + self.label = Gtk.Label() + self.label.show() + if self.for_validate: + stock_id = Gtk.STOCK_DIALOG_WARNING + else: + stock_id = Gtk.STOCK_CONVERT + image = Gtk.Image.new_from_stock(stock_id, Gtk.IconSize.LARGE_TOOLBAR) + image.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start( + image, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + hbox.pack_start( + self.label, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + hbox.show() + self.treewindow = Gtk.ScrolledWindow() + self.treewindow.show() + self.treewindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + self.treeview = metomi.rose.gtk.util.TooltipTreeView( + get_tooltip_func=self._get_tooltip + ) + self.treeview.show() + self.treemodel = Gtk.TreeStore(str, str, str, str, str) + + self.treeview.set_model(self.treemodel) + for i, title in enumerate(self.COLUMNS): + column = Gtk.TreeViewColumn() + column.set_title(title) + cell = Gtk.CellRendererText() + if i == len(self.COLUMNS) - 1: + column.pack_start(cell, True) + else: + column.pack_start(cell, False) + if title == "Type": + column.set_cell_data_func(cell, self._set_type_markup, i) + else: + column.set_cell_data_func(cell, self._set_markup, i) + self.treeview.append_column(column) + + self.treeview.connect( + "row-activated", self._handle_treeview_activation + ) + self.treewindow.add(self.treeview) + self.vbox.pack_end( + self.treewindow, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + self.vbox.pack_end( + hbox, + expand=False, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + self.set_focus(self.action_area.get_children()[0]) + + def display(self, changes): + if not changes: + # Shortcut, no changes. + if self.for_validate: + title = ( + metomi.rose.config_editor.DIALOG_TITLE_MACRO_VALIDATE_NONE + ) + text = ( + metomi.rose.config_editor.DIALOG_LABEL_MACRO_VALIDATE_NONE + ) + else: + title = ( + metomi.rose.config_editor.DIALOG_TITLE_MACRO_TRANSFORM_NONE + ) + text = ( + metomi.rose.config_editor.DIALOG_LABEL_MACRO_TRANSFORM_NONE + ) + title = title.format(self.short_macro_name) + text = metomi.rose.gtk.util.safe_str(text) + return metomi.rose.gtk.dialog.run_dialog( + metomi.rose.gtk.dialog.DIALOG_TYPE_INFO, text, title + ) + if self.for_validate: + text = metomi.rose.config_editor.DIALOG_LABEL_MACRO_VALIDATE_ISSUES + else: + text = ( + metomi.rose.config_editor.DIALOG_LABEL_MACRO_TRANSFORM_CHANGES + ) + nums_is_warning = {True: 0, False: 0} + for item in changes: + nums_is_warning[item.is_warning] += 1 + text = text.format( + self.short_macro_name, + self.short_config_name, + nums_is_warning[False], + ) + if nums_is_warning[True]: + extra_text = ( + metomi.rose.config_editor.DIALOG_LABEL_MACRO_WARN_ISSUES + ) + text = ( + text.rstrip() + " " + extra_text.format(nums_is_warning[True]) + ) + self.label.set_markup(text) + changes.sort(key=lambda x: str(x.option)) + changes.sort(key=lambda x: str(x.section)) + changes.sort(key=lambda x: x.is_warning) + last_section = None + last_section_iter = None + for item in changes: + item_mode = self.mode + if item.is_warning: + item_mode = "warn" + item_att_list = [ + item.section, + item.option, + item_mode, + item.value, + item.info, + ] + if item.section == last_section: + self.treemodel.append(last_section_iter, item_att_list) + else: + sect_att_list = [item.section, None, None, None, None] + last_section_iter = self.treemodel.append(None, sect_att_list) + last_section = item.section + self.treemodel.append(last_section_iter, item_att_list) + self.treeview.expand_all() + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + my_size = self.size_request() + new_size = [-1, -1] + # this needs checking + new_size[0] = min([my_size.width, max_size[0]]) + new_size[1] = min([my_size.height, max_size[1]]) + self.treewindow.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + self.set_default_size(*new_size) + if self.for_transform: + response = self.run() + self.destroy() + return response == Gtk.ResponseType.ACCEPT + else: + self.show() + self.action_area.get_children()[0].connect( + "clicked", lambda b: self.destroy() + ) + + def _get_tooltip(self, view, row_iter, col_index, tip): + tip.set_text(view.get_model().get_value(row_iter, col_index)) + return True + + def _set_type_markup(self, column, cell, model, r_iter, col_index): + macro_mode = model.get_value(r_iter, col_index) + if macro_mode is None: + cell.set_property("markup", None) + else: + cell.set_property("markup", self._get_type_markup(macro_mode)) + + def _get_type_markup(self, macro_mode): + colour = self.MODE_COLOURS[macro_mode] + text = self.MODE_TEXT[macro_mode] + return '{1}'.format(colour, text) + + def _set_markup(self, column, cell, model, r_iter, col_index): + text = model.get_value(r_iter, col_index) + if text is None: + cell.set_property("markup", None) + else: + cell.set_property("markup", metomi.rose.gtk.util.safe_str(text)) + if col_index == 0: + cell.set_property("visible", (len(model.get_path(r_iter)) == 1)) + + def _handle_treeview_activation(self, view, path, column): + r_iter = view.get_model().get_iter(path) + section = view.get_model().get_value(r_iter, 0) + option = view.get_model().get_value(r_iter, 1) + id_ = self.util.get_id_from_section_option(section, option) + self.search_func(id_) diff --git a/metomi/rose/etc/images/rose-config-edit/change_icon.png b/metomi/rose/etc/images/rose-config-edit/change_icon.png new file mode 100644 index 0000000000..a6bd3f0799 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/change_icon.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/error_icon.png b/metomi/rose/etc/images/rose-config-edit/error_icon.png new file mode 100644 index 0000000000..fb578f1e64 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/error_icon.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_add.png b/metomi/rose/etc/images/rose-config-edit/gnome_add.png new file mode 100644 index 0000000000..80985a300d Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_add.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_add_errors.png b/metomi/rose/etc/images/rose-config-edit/gnome_add_errors.png new file mode 100644 index 0000000000..ee4be143f8 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_add_errors.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_add_warnings.png b/metomi/rose/etc/images/rose-config-edit/gnome_add_warnings.png new file mode 100644 index 0000000000..8180664fb0 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_add_warnings.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_package_system.png b/metomi/rose/etc/images/rose-config-edit/gnome_package_system.png new file mode 100644 index 0000000000..584725230e Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_package_system.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_package_system_errors.png b/metomi/rose/etc/images/rose-config-edit/gnome_package_system_errors.png new file mode 100644 index 0000000000..a05e498627 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_package_system_errors.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/gnome_package_system_warnings.png b/metomi/rose/etc/images/rose-config-edit/gnome_package_system_warnings.png new file mode 100644 index 0000000000..22baf7727f Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/gnome_package_system_warnings.png differ diff --git a/metomi/rose/etc/images/rose-config-edit/null_icon.png b/metomi/rose/etc/images/rose-config-edit/null_icon.png new file mode 100644 index 0000000000..660cd96053 Binary files /dev/null and b/metomi/rose/etc/images/rose-config-edit/null_icon.png differ diff --git a/etc/images/rose-icon-trim.png b/metomi/rose/etc/images/rose-icon-trim.png similarity index 100% rename from etc/images/rose-icon-trim.png rename to metomi/rose/etc/images/rose-icon-trim.png diff --git a/etc/images/rose-icon-trim.svg b/metomi/rose/etc/images/rose-icon-trim.svg similarity index 100% rename from etc/images/rose-icon-trim.svg rename to metomi/rose/etc/images/rose-icon-trim.svg diff --git a/etc/images/rose-icon.png b/metomi/rose/etc/images/rose-icon.png similarity index 100% rename from etc/images/rose-icon.png rename to metomi/rose/etc/images/rose-icon.png diff --git a/etc/images/rose-icon.svg b/metomi/rose/etc/images/rose-icon.svg similarity index 100% rename from etc/images/rose-icon.svg rename to metomi/rose/etc/images/rose-icon.svg diff --git a/etc/images/rose-logo.png b/metomi/rose/etc/images/rose-logo.png similarity index 100% rename from etc/images/rose-logo.png rename to metomi/rose/etc/images/rose-logo.png diff --git a/metomi/rose/etc/images/rose-splash-logo.png b/metomi/rose/etc/images/rose-splash-logo.png new file mode 100644 index 0000000000..897568bae4 Binary files /dev/null and b/metomi/rose/etc/images/rose-splash-logo.png differ diff --git a/etc/images/rosie-icon-trim.png b/metomi/rose/etc/images/rosie-icon-trim.png similarity index 100% rename from etc/images/rosie-icon-trim.png rename to metomi/rose/etc/images/rosie-icon-trim.png diff --git a/etc/images/rosie-icon-trim.svg b/metomi/rose/etc/images/rosie-icon-trim.svg similarity index 100% rename from etc/images/rosie-icon-trim.svg rename to metomi/rose/etc/images/rosie-icon-trim.svg diff --git a/etc/images/rosie-icon.png b/metomi/rose/etc/images/rosie-icon.png similarity index 100% rename from etc/images/rosie-icon.png rename to metomi/rose/etc/images/rosie-icon.png diff --git a/etc/images/rosie-icon.svg b/metomi/rose/etc/images/rosie-icon.svg similarity index 100% rename from etc/images/rosie-icon.svg rename to metomi/rose/etc/images/rosie-icon.svg diff --git a/metomi/rose/etc/rose-config-edit/.gtkrc-2.0 b/metomi/rose/etc/rose-config-edit/.gtkrc-2.0 new file mode 100644 index 0000000000..f8bd295494 --- /dev/null +++ b/metomi/rose/etc/rose-config-edit/.gtkrc-2.0 @@ -0,0 +1 @@ +gtk-icon-sizes = "gtk-large-toolbar=20,20:gtk-small-toolbar=20,20:panel-menu=16,16:gtk-button=16,16" diff --git a/metomi/rose/etc/rose-config-edit/style.css b/metomi/rose/etc/rose-config-edit/style.css new file mode 100644 index 0000000000..857fbd5e8c --- /dev/null +++ b/metomi/rose/etc/rose-config-edit/style.css @@ -0,0 +1,40 @@ +/* Add padding in-between env cogs and the variable title on pages */ +button > widget > box > image { + padding-right: 10px; +} + +popover > box > button > box > image { + padding-right: 10px; +} + +/* Add an outline to indicate focus when navigating using internal links */ +notebook button:focus { + outline-style: dashed; + outline-color: gray; +} + +/* Fix the close button for tab page labels */ +#page-tab-button { + border: None; + padding-right: 10px; +} + +#page-tab-button:hover { + background-image: None; + box-shadow: None; +} + +#macro-button { + padding: 5px; + margin-top: 10px; + margin-left: 10px; +} + +/* Underline key and variable widgets so rows of widgets are distinct */ +notebook box > paned > paned > scrolledwindow > viewport > box > widget > box { + border-bottom: solid; + border-width: 1px; + border-color: lightgray; + padding: .4em; + margin: 0 -.3em; + } diff --git a/metomi/rose/etc/rose-meta/rose-suite-info/rose-meta.conf b/metomi/rose/etc/rose-meta/rose-suite-info/rose-meta.conf index 20f546cdd5..bf3bcf2d46 100644 --- a/metomi/rose/etc/rose-meta/rose-suite-info/rose-meta.conf +++ b/metomi/rose/etc/rose-meta/rose-suite-info/rose-meta.conf @@ -25,7 +25,7 @@ sort-key=02-users-0 [=description] description=A long description of the suite. help=A long description of the suite - multi-lines accepted. Inheritance of the suite is included here. -widget[rose-config-edit]=rose.config_editor.valuewidget.text.TextMultilineValueWidget +widget[rose-config-edit]=metomi.rose.config_editor.valuewidget.text.TextMultilineValueWidget pattern=^.+(?# Must not be empty) sort-key=03-type-1 diff --git a/metomi/rose/etc/tutorial/widget/meta/lib/python/widget/username.py b/metomi/rose/etc/tutorial/widget/meta/lib/python/widget/username.py index 2ad5ebf539..aef9248f7b 100644 --- a/metomi/rose/etc/tutorial/widget/meta/lib/python/widget/username.py +++ b/metomi/rose/etc/tutorial/widget/meta/lib/python/widget/username.py @@ -9,14 +9,14 @@ from functools import partial -import pygtk +import gi -pygtk.require('2.0') +gi.require_version('Gtk', '3.0') # flake8: noqa: E402 -import gtk +from gi.repository import Gtk -class UsernameValueWidget(gtk.HBox): +class UsernameValueWidget(Gtk.Box): """This class generates a widget for entering usernames.""" @@ -26,7 +26,7 @@ def __init__(self, value, metadata, set_value, hook, arg_str=None): self.metadata = metadata self.set_value = set_value self.hook = hook - self.entry = gtk.Entry() + self.entry = Gtk.Entry() self.entry.set_text(self.value) self.entry.connect_after("paste-clipboard", self._setter) self.entry.connect_after("key-release-event", self._setter) diff --git a/metomi/rose/gtk/__init__.py b/metomi/rose/gtk/__init__.py new file mode 100644 index 0000000000..e79c307c69 --- /dev/null +++ b/metomi/rose/gtk/__init__.py @@ -0,0 +1,9 @@ +try: + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk # noqa: F401 +except (ImportError, RuntimeError, AssertionError): + INTERACTIVE_ENABLED = False +else: + INTERACTIVE_ENABLED = True diff --git a/metomi/rose/gtk/choice.py b/metomi/rose/gtk/choice.py new file mode 100644 index 0000000000..76d5109c03 --- /dev/null +++ b/metomi/rose/gtk/choice.py @@ -0,0 +1,489 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk + +import metomi.rose + + +class ChoicesListView(Gtk.TreeView): + """Class to hold and display an ordered list of strings. + + set_value is a function, accepting a new value string. + get_data is a function that accepts no arguments and returns an + ordered list of included names to display. + handle_search is a function that accepts a name and triggers a + search for it. + title is a string or Gtk.Widget displayed as the column header, if + given. + get_custom_menu_items, if given, should be a function that + accepts no arguments and returns a list of Gtk.MenuItem-derived + instances. The listview model and current TreeIter will be + available as attributes "_listview_model" and "_listview_iter" set + on each menu item to optionally use during the menu item callbacks + - this means that they can use them to modify the model + information. Menuitems that do this should connect to + "button-press-event", as the model cleanup will take place as a + connect_after to the same event. + + """ + + def __init__( + self, + set_value, + get_data, + handle_search, + title=metomi.rose.config_editor.CHOICE_TITLE_INCLUDED, + get_custom_menu_items=lambda: [], + ): + super(ChoicesListView, self).__init__() + self._set_value = set_value + self._get_data = get_data + self._handle_search = handle_search + self._get_custom_menu_items = get_custom_menu_items + self.enable_model_drag_dest( + [("text/plain", 0, 0)], Gdk.DragAction.MOVE + ) + self.enable_model_drag_source( + Gdk.ModifierType.BUTTON1_MASK, + [("text/plain", 0, 0)], + Gdk.DragAction.MOVE, + ) + self.connect("button-press-event", self._handle_button_press) + self.connect("drag-data-get", self._handle_drag_get) + self.connect_after("drag-data-received", self._handle_drag_received) + self.set_rules_hint(True) + self.connect("row-activated", self._handle_activation) + self.show() + col = Gtk.TreeViewColumn() + if isinstance(title, Gtk.Widget): + col.set_widget(title) + else: + col.set_title(title) + cell_text = Gtk.CellRendererText() + cell_text.set_property("editable", True) + cell_text.connect("edited", self._handle_edited) + col.pack_start(cell_text, True) + col.set_cell_data_func(cell_text, self._set_cell_text, None) + self.append_column(col) + self._populate() + + def _handle_activation(self, treeview, path, col): + """Handle a click on the main list view - start a search.""" + iter_ = treeview.get_model().get_iter(path) + name = treeview.get_model().get_value(iter_, 0) + self._handle_search(name) + return False + + def _handle_button_press(self, treeview, event): + """Handle a right click event on the main list view.""" + if not hasattr(event, "button") or event.button != 3: + return False + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return False + iter_ = treeview.get_model().get_iter(pathinfo[0]) + self._popup_menu(iter_, event) + return False + + def _handle_drag_get(self, treeview, drag, sel, info, time): + """Handle an outgoing drag request.""" + model, iter_ = treeview.get_selection().get_selected() + text = model.get_value(iter_, 0) + sel.set_text(text) + model.remove(iter_) # Triggers the 'row-deleted' signal, sets value + if not model.iter_n_children(None): + model.append([metomi.rose.config_editor.CHOICE_LABEL_EMPTY]) + + def _handle_drag_received( + self, treeview, drag, xpos, ypos, sel, info, time + ): + """Handle an incoming drag request.""" + if sel.data is None: + return False + drop_info = treeview.get_dest_row_at_pos(xpos, ypos) + model = treeview.get_model() + if drop_info: + path, position = drop_info + if ( + position == Gtk.TreeViewDropPosition.BEFORE + or position == Gtk.TreeViewDropPosition.INTO_OR_BEFORE + ): + model.insert(path[0], [sel.data]) + else: + model.insert(path[0] + 1, [sel.data]) + else: + model.append([sel.data]) + path = None + self._handle_reordering(model, path) + + def _handle_edited(self, cell, path, new_text): + """Handle cell text so it can be edited.""" + liststore = self.get_model() + iter_ = liststore.get_iter(path) + liststore.set_value(iter_, 0, new_text) + self._handle_reordering() + return + + def _handle_reordering(self, model=None, path=None): + """Handle a drag-and-drop rearrangement in the main list view.""" + if model is None: + model = self.get_model() + ok_values = [] + iter_ = model.get_iter_first() + num_entries = model.iter_n_children(None) + while iter_ is not None: + name = model.get_value(iter_, 0) + next_iter = model.iter_next(iter_) + if name == metomi.rose.config_editor.CHOICE_LABEL_EMPTY: + if num_entries > 1: + model.remove(iter_) + else: + ok_values.append(name) + iter_ = next_iter + new_value = " ".join(ok_values) + self._set_value(new_value) + + def _populate(self): + """Populate the main list view.""" + values = self._get_data() + model = Gtk.ListStore(str) + if not values: + values = [metomi.rose.config_editor.CHOICE_LABEL_EMPTY] + for value in values: + model.append([value]) + model.connect_after("row-deleted", self._handle_reordering) + self.set_model(model) + + def _popup_menu(self, iter_, event): + # Pop up a menu for the main list view. + """Launch a popup menu for add/clone/remove.""" + ui_config_string = """ + + """ + text = metomi.rose.config_editor.CHOICE_MENU_REMOVE + actions = [("Remove", Gtk.STOCK_DELETE, text)] + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup("Popup") + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup) + uimanager.add_ui_from_string(ui_config_string) + remove_item = uimanager.get_widget("/Popup/Remove") + remove_item.connect("activate", lambda b: self._remove_iter(iter_)) + menu = uimanager.get_widget("/Popup") + for menuitem in self._get_custom_menu_items(): + menuitem._listview_model = self.get_model() + menuitem._listview_iter = iter_ + menuitem.connect_after( + "button-press-event", lambda b, e: self._handle_reordering() + ) + menu.append(menuitem) + menu.popup_at_widget( + event.button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + return False + + def _remove_iter(self, iter_): + self.get_model().remove(iter_) + if self.get_model() is None: + # Removing the last iter makes get_model return None... + self._populate() + self._handle_reordering() + self._populate() + + def _set_cell_text(self, column, cell, model, r_iter, _): + name = model.get_value(r_iter, 0) + if name == metomi.rose.config_editor.CHOICE_LABEL_EMPTY: + cell.set_property("markup", "" + name + "") + else: + cell.set_property("markup", "" + name + "") + + def refresh(self): + """Update the model values.""" + self._populate() + + +class ChoicesTreeView(Gtk.TreeView): + """Class to hold and display a tree of content. + + set_value is a function, accepting a new value string. + get_data is a function that accepts no arguments and returns a + list of included names. + get_available_data is a function that accepts no arguments and + returns a list of available names. + get_groups is a function that accepts a name and a list of + available names and returns groups that supercede name. + get_is_implicit is an optional function that accepts a name and + returns whether the name is implicitly included in the content. + title is a string displayed as the column header, if given. + get_is_included is an optional function that accepts a name and + an optional list of included names to test whether a + name is already included. + + """ + + def __init__( + self, + set_value, + get_data, + get_available_data, + get_groups, + get_is_implicit=None, + title=metomi.rose.config_editor.CHOICE_TITLE_AVAILABLE, + get_is_included=None, + ): + super(ChoicesTreeView, self).__init__() + # Generate the 'available' sections view. + self._set_value = set_value + self._get_data = get_data + self._get_available_data = get_available_data + self._get_groups = get_groups + self._get_is_implicit = get_is_implicit + self._get_is_included_func = get_is_included + self.set_headers_visible(True) + self.set_rules_hint(True) + self.enable_model_drag_dest( + [("text/plain", 0, 0)], Gdk.DragAction.MOVE + ) + self.enable_model_drag_source( + Gdk.ModifierType.BUTTON1_MASK, + [("text/plain", 0, 0)], + Gdk.DragAction.MOVE, + ) + self.connect_after("button-release-event", self._handle_button) + self.connect("drag-begin", self._handle_drag_begin) + self.connect("drag-data-get", self._handle_drag_get) + self.connect("drag-end", self._handle_drag_end) + self._is_dragging = False + model = Gtk.TreeStore(str, bool, bool) + self.set_model(model) + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect_after("toggled", self._handle_cell_toggle) + col.pack_start(cell_toggle, False) + col.set_cell_data_func(cell_toggle, self._set_cell_state, None) + self.append_column(col) + col = Gtk.TreeViewColumn() + col.set_title(title) + cell_text = Gtk.CellRendererText() + col.pack_start(cell_text, True) + col.set_cell_data_func(cell_text, self._set_cell_text, None) + self.append_column(col) + self.set_expander_column(col) + self.show() + self._populate() + + def _get_is_included(self, name, ok_names=None): + if self._get_is_included_func is not None: + return self._get_is_included_func(name, ok_names) + if ok_names is None: + ok_names = self._get_available_data() + return name in ok_names + + def _populate(self): + """Populate the 'available' sections view.""" + ok_content_sections = self._get_available_data() + self._ok_content_sections = set(ok_content_sections) + ok_values = self._get_data() + model = self.get_model() + sections_left = list(ok_content_sections) + self._name_iter_map = {} + while sections_left: + name = sections_left.pop(0) + is_included = self._get_is_included(name, ok_values) + groups = self._get_groups(name, ok_content_sections) + if self._get_is_implicit is None: + is_implicit = any( + [self._get_is_included(g, ok_values) for g in groups] + ) + else: + is_implicit = self._get_is_implicit(name) + if groups: + iter_ = model.append( + self._name_iter_map[groups[-1]], + [name, is_included, is_implicit], + ) + else: + iter_ = model.append(None, [name, is_included, is_implicit]) + self._name_iter_map[name] = iter_ + + def _realign(self): + """Refresh the states in the model.""" + ok_values = self._get_data() + model = self.get_model() + ok_content_sections = self._get_available_data() + for name, iter_ in list(self._name_iter_map.items()): + is_in_value = self._get_is_included(name, ok_values) + if self._get_is_implicit is None: + groups = self._get_groups(name, ok_content_sections) + is_implicit = any( + [self._get_is_included(g, ok_values) for g in groups] + ) + else: + is_implicit = self._get_is_implicit(name) + if model.get_value(iter_, 1) != is_in_value: + model.set_value(iter_, 1, is_in_value) + if model.get_value(iter_, 2) != is_implicit: + model.set_value(iter_, 2, is_implicit) + + def _set_cell_text(self, column, cell, model, r_iter, _): + """Set markup for a section depending on its status.""" + section_name = model.get_value(r_iter, 0) + is_in_value = model.get_value(r_iter, 1) + is_implicit = model.get_value(r_iter, 2) + r_iter = model.iter_children(r_iter) + while r_iter is not None: + if model.get_value(r_iter, 1): + is_in_value = True + break + r_iter = model.iter_next(r_iter) + if is_in_value: + cell.set_property("markup", "{0}".format(section_name)) + cell.set_property("sensitive", True) + elif is_implicit: + cell.set_property("markup", "{0}".format(section_name)) + cell.set_property("sensitive", False) + else: + cell.set_property("markup", section_name) + cell.set_property("sensitive", True) + + def _set_cell_state(self, column, cell, model, r_iter, _): + """Set the check box for a section depending on its status.""" + is_in_value = model.get_value(r_iter, 1) + is_implicit = model.get_value(r_iter, 2) + if is_in_value: + cell.set_property("active", True) + cell.set_property("sensitive", True) + elif is_implicit: + cell.set_property("active", True) + cell.set_property("sensitive", False) + else: + cell.set_property("active", False) + cell.set_property("sensitive", True) + if not self._check_can_add(r_iter): + cell.set_property("sensitive", False) + + def _handle_drag_begin(self, widget, drag): + self._is_dragging = True + + def _handle_drag_end(self, widget, drag): + self._is_dragging = False + + def _handle_drag_get(self, treeview, drag, sel, info, time): + """Handle a drag data get.""" + model, iter_ = treeview.get_selection().get_selected() + if not self._check_can_add(iter_): + return False + name = model.get_value(iter_, 0) + sel.set("text/plain", 8, name) + + def _check_can_add(self, iter_): + """Check whether a name can be added to the data.""" + model = self.get_model() + if model.get_value(iter_, 1) or model.get_value(iter_, 2): + return False + child_iter = model.iter_children(iter_) + while child_iter is not None: + if model.get_value(child_iter, 1) or model.get_value( + child_iter, 2 + ): + return False + child_iter = model.iter_next(child_iter) + return True + + def _handle_button(self, treeview, event): + """Connect a left click on the available section to a toggle.""" + if event.button != 1 or self._is_dragging: + return False + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return False + path, col = pathinfo[0:2] + if treeview.get_columns().index(col) == 1: + self._handle_cell_toggle(None, path) + + def _handle_cell_toggle(self, cell, path, should_turn_off=None): + """Change the content variable value here. + + cell is not used. + path is the name to turn off or on. + should_turn_off is as follows: + None - toggle based on the cell value + False - toggle on + True - toggle off + + """ + text_index = 0 + model = self.get_model() + r_iter = model.get_iter(path) + this_name = model.get_value(r_iter, text_index) + ok_values = self._get_data() + model = self.get_model() + can_add = self._check_can_add(r_iter) + should_add = False + if ( + should_turn_off is None or should_turn_off + ) and self._get_is_included(this_name, ok_values): + ok_values.remove(this_name) + elif should_turn_off is None or not should_turn_off: + if not can_add: + return False + should_add = True + ok_values = ok_values + [this_name] + else: + self._realign() + return False + model.set_value(r_iter, 1, should_add) + if model.iter_n_children(r_iter): + self._toggle_internal_base(r_iter, this_name, should_add) + self._set_value(" ".join(ok_values)) + self._realign() + return False + + def _toggle_internal_base(self, base_iter, base_name, added=False): + """Connect a toggle of a group to its children. + + base_iter is the iter pointing to the group + base_name is the name of the group + added is a boolean denoting toggle state + + """ + model = self.get_model() + iter_ = model.iter_children(base_iter) + skip_children = False + while iter_ is not None: + model.set_value(iter_, 2, added) + if not skip_children: + next_iter = model.iter_children(iter_) + if skip_children or next_iter is None: + next_iter = model.iter_next(iter_) + skip_children = False + if next_iter is None: + next_iter = model.iter_parent(iter_) + skip_children = True + iter_ = next_iter + return False + + def refresh(self): + """Refresh the model.""" + self._realign() diff --git a/metomi/rose/gtk/console.py b/metomi/rose/gtk/console.py new file mode 100644 index 0000000000..f6959c8d8b --- /dev/null +++ b/metomi/rose/gtk/console.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import datetime + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.resource + + +class ConsoleWindow(Gtk.Window): + """Create an error console window.""" + + CATEGORY_ALL = "All" + COLUMN_TITLE_CATEGORY = "Type" + COLUMN_TITLE_MESSAGE = "Message" + COLUMN_TITLE_TIME = "Time" + DEFAULT_SIZE = (600, 300) + TITLE = "Error Console" + + def __init__( + self, + categories, + category_message_time_tuples, + category_stock_ids, + default_size=None, + parent=None, + destroy_hook=None, + ): + super(ConsoleWindow, self).__init__() + if parent is not None: + self.set_transient_for(parent) + if default_size is None: + default_size = self.DEFAULT_SIZE + self.set_default_size(*default_size) + self.set_title(self.TITLE) + self._filter_category = self.CATEGORY_ALL + self.categories = categories + self.category_icons = [] + for id_ in category_stock_ids: + self.category_icons.append( + self.render_icon(id_, Gtk.IconSize.MENU) + ) + self._destroy_hook = destroy_hook + top_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + top_vbox.show() + self.add(top_vbox) + + message_scrolled_window = Gtk.ScrolledWindow() + message_scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + message_scrolled_window.show() + self._message_treeview = Gtk.TreeView() + self._message_treeview.show() + self._message_treeview.set_rules_hint(True) + + # Set up the category column (icons). + category_column = Gtk.TreeViewColumn() + category_column.set_title(self.COLUMN_TITLE_CATEGORY) + cell_category = Gtk.CellRendererPixbuf() + category_column.pack_start(cell_category, False) + category_column.set_cell_data_func( + cell_category, self._set_category_cell, 0 + ) + category_column.set_clickable(True) + category_column.connect("clicked", self._sort_column, 0) + self._message_treeview.append_column(category_column) + + # Set up the message column (info text). + message_column = Gtk.TreeViewColumn() + message_column.set_title(self.COLUMN_TITLE_MESSAGE) + cell_message = Gtk.CellRendererText() + message_column.pack_start(cell_message, False) + message_column.add_attribute(cell_message, attribute="text", column=1) + message_column.set_clickable(True) + message_column.connect("clicked", self._sort_column, 1) + self._message_treeview.append_column(message_column) + + # Set up the time column (text). + time_column = Gtk.TreeViewColumn() + time_column.set_title(self.COLUMN_TITLE_TIME) + cell_time = Gtk.CellRendererText() + time_column.pack_start(cell_time, False) + time_column.set_cell_data_func(cell_time, self._set_time_cell, 2) + time_column.set_clickable(True) + time_column.set_sort_indicator(True) + time_column.connect("clicked", self._sort_column, 2) + self._message_treeview.append_column(time_column) + + self._message_store = Gtk.TreeStore(str, str, int) + for category, message, time in category_message_time_tuples: + self._message_store.append(None, [category, message, time]) + filter_model = self._message_store.filter_new() + filter_model.set_visible_func(self._get_should_show) + self._message_treeview.set_model(filter_model) + + message_scrolled_window.add(self._message_treeview) + top_vbox.pack_start( + message_scrolled_window, expand=True, fill=True, padding=0 + ) + + category_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + category_hbox.show() + top_vbox.pack_end(category_hbox, expand=False, fill=False, padding=0) + for category in categories + [self.CATEGORY_ALL]: + togglebutton = Gtk.ToggleButton( + label=category, use_underline=False + ) + togglebutton.connect( + "toggled", + lambda b: self._set_new_filter( + b, category_hbox.get_children() + ), + ) + togglebutton.show() + category_hbox.pack_start( + togglebutton, expand=True, fill=True, padding=0 + ) + togglebutton.set_active(True) + self.show() + self._scroll_to_end() + self.connect("destroy", self._handle_destroy) + + def _handle_destroy(self, window): + if self._destroy_hook is not None: + self._destroy_hook() + + def _get_should_show(self, model, iter_, _): + # Determine whether to show a row. + category = model.get_value(iter_, 0) + if self._filter_category not in [self.CATEGORY_ALL, category]: + return False + return True + + def _scroll_to_end(self): + # Scroll the Treeview to the end of the rows. + model = self._message_treeview.get_model() + iter_ = model.get_iter_first() + if iter_ is None: + return + while True: + next_iter = model.iter_next(iter_) + if next_iter is None: + break + iter_ = next_iter + path = model.get_path(iter_) + self._message_treeview.scroll_to_cell(path) + self._message_treeview.set_cursor(path) + self._message_treeview.grab_focus() + + def _set_category_cell(self, column, cell, model, r_iter, index): + category = model.get_value(r_iter, index) + icon = self.category_icons[self.categories.index(category)] + cell.set_property("pixbuf", icon) + + def _set_new_filter(self, togglebutton, togglebuttons): + category = togglebutton.get_label() + if not togglebutton.get_active(): + return False + self._filter_category = category + self._message_treeview.get_model().refilter() + for button in togglebuttons: + if button != togglebutton: + button.set_active(False) + + def _set_time_cell(self, column, cell, model, r_iter, index): + message_time = model.get_value(r_iter, index) + text = datetime.datetime.fromtimestamp(message_time).strftime( + metomi.rose.config_editor.EVENT_TIME_LONG + ) + cell.set_property("text", text) + + def _sort_column(self, column, index): + # Sort a column. + new_sort_order = Gtk.SortType.ASCENDING + if column.get_sort_order() == Gtk.SortType.ASCENDING: + new_sort_order = Gtk.SortType.DESCENDING + column.set_sort_order(new_sort_order) + for other_column in self._message_treeview.get_columns(): + other_column.set_sort_indicator(column == other_column) + self._message_store.set_sort_column_id(index, new_sort_order) + + def update_messages(self, category_message_time_tuples): + # Update the messages. + self._message_store.clear() + for category, message, time in category_message_time_tuples: + self._message_store.append(None, [category, message, time]) + self._scroll_to_end() diff --git a/metomi/rose/gtk/dialog.py b/metomi/rose/gtk/dialog.py new file mode 100644 index 0000000000..9d60a09214 --- /dev/null +++ b/metomi/rose/gtk/dialog.py @@ -0,0 +1,853 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +from multiprocessing import Process +import queue +import shlex +from subprocess import Popen, PIPE +import sys +import tempfile +import time +import traceback + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GdkPixbuf +from gi.repository import GLib +from gi.repository import Pango + +import metomi.rose.gtk.util +import metomi.rose.resource + + +DIALOG_BUTTON_CLOSE = "Close" +DIALOG_LABEL_README = "README" +DIALOG_PADDING = 10 +DIALOG_SUB_PADDING = 5 + +DIALOG_SIZE_PROCESS = (400, 100) +DIALOG_SIZE_SCROLLED_MAX = (600, 600) +DIALOG_SIZE_SCROLLED_MIN = (300, 100) + +DIALOG_TEXT_SHUTDOWN_ASAP = "Shutdown ASAP." +DIALOG_TEXT_SHUTTING_DOWN = "Shutting down." +DIALOG_TEXT_UNCAUGHT_EXCEPTION = ( + "{0} has crashed. {1}" + "\n\n{2}: {3}\n{4}" +) +DIALOG_TITLE_ERROR = "Error" +DIALOG_TITLE_UNCAUGHT_EXCEPTION = "Critical error" +DIALOG_TITLE_EXTRA_INFO = "Further information" +DIALOG_TYPE_ERROR = Gtk.MessageType.ERROR +DIALOG_TYPE_INFO = Gtk.MessageType.INFO +DIALOG_TYPE_WARNING = Gtk.MessageType.WARNING + + +class DialogProcess(object): + """Run a forked process and display a dialog while it runs. + + cmd_args can either be a list of shell command components + e.g. ['sleep', '100'] or a list containing a python function + followed by any function arguments e.g. [func, '100']. + description is used for the label, if not None + title is used for the title, if not None + stock_id is used for the dialog icon + hide_progress removes the bouncing progress bar + + Returns the exit code of the process. + + """ + + DIALOG_FUNCTION_LABEL = "Executing function" + DIALOG_LOG_LABEL = "Show log" + DIALOG_PROCESS_LABEL = "Executing command" + + def __init__( + self, + cmd_args, + description=None, + title=None, + stock_id=Gtk.STOCK_EXECUTE, + hide_progress=False, + modal=True, + event_queue=None, + ): + self.proc = None + window = get_dialog_parent() + self.dialog = Gtk.Dialog( + buttons=(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT), parent=window + ) + self.dialog.set_modal(modal) + self.dialog.set_default_size(*DIALOG_SIZE_PROCESS) + self._is_destroyed = False + self.dialog.set_icon( + self.dialog.render_icon(Gtk.STOCK_EXECUTE, Gtk.IconSize.MENU) + ) + self.cmd_args = cmd_args + self.event_queue = event_queue + str_cmd_args = [metomi.rose.gtk.util.safe_str(a) for a in cmd_args] + if description is not None: + str_cmd_args = [description] + if title is None: + self.dialog.set_title(" ".join(str_cmd_args[0:2])) + else: + self.dialog.set_title(title) + if callable(cmd_args[0]): + self.label = Gtk.Label(label=self.DIALOG_FUNCTION_LABEL) + else: + self.label = Gtk.Label(label=self.DIALOG_PROCESS_LABEL) + self.label.set_use_markup(True) + self.label.show() + self.image = Gtk.Image.new_from_stock(stock_id, Gtk.IconSize.DIALOG) + self.image.show() + image_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + image_vbox.pack_start(self.image, expand=False, fill=False, padding=0) + image_vbox.show() + top_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + top_hbox.pack_start( + image_vbox, expand=False, fill=False, padding=DIALOG_PADDING + ) + top_hbox.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start( + self.label, expand=False, fill=False, padding=DIALOG_PADDING + ) + hbox.show() + main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_vbox.show() + main_vbox.pack_start( + hbox, expand=False, fill=False, padding=DIALOG_SUB_PADDING + ) + + cmd_string = str_cmd_args[0] + if str_cmd_args[1:]: + if callable(cmd_args[0]): + cmd_string += "(" + " ".join(str_cmd_args[1:]) + ")" + else: + cmd_string += " " + " ".join(str_cmd_args[1:]) + self.cmd_label = Gtk.Label() + self.cmd_label.set_markup("" + cmd_string + "") + self.cmd_label.show() + cmd_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + cmd_hbox.pack_start( + self.cmd_label, expand=False, fill=False, padding=DIALOG_PADDING + ) + cmd_hbox.show() + main_vbox.pack_start( + cmd_hbox, expand=False, fill=True, padding=DIALOG_SUB_PADDING + ) + # self.dialog.set_modal(True) + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_pulse_step(0.1) + self.progress_bar.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start( + self.progress_bar, expand=True, fill=True, padding=DIALOG_PADDING + ) + hbox.show() + main_vbox.pack_start( + hbox, expand=False, fill=False, padding=DIALOG_SUB_PADDING + ) + top_hbox.pack_start( + main_vbox, expand=True, fill=True, padding=DIALOG_PADDING + ) + if self.event_queue is None: + self.dialog.vbox.pack_start( + top_hbox, expand=True, fill=True, padding=0 + ) + else: + text_view_scroll = Gtk.ScrolledWindow() + text_view_scroll.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + text_view_scroll.show() + text_view = Gtk.TextView() + text_view.show() + self.text_buffer = text_view.get_buffer() + self.text_tag = self.text_buffer.create_tag() + self.text_tag.set_property("scale", Pango.SCALE_SMALL) + text_view.connect("size-allocate", self._handle_scroll_text_view) + text_view_scroll.add(text_view) + text_expander = Gtk.Expander(self.DIALOG_LOG_LABEL) + text_expander.set_spacing(DIALOG_SUB_PADDING) + text_expander.add(text_view_scroll) + text_expander.show() + top_pane = Gtk.VPaned() + top_pane.pack1(top_hbox, resize=False, shrink=False) + top_pane.show() + self.dialog.vbox.pack_start( + top_pane, expand=True, fill=True, padding=DIALOG_SUB_PADDING + ) + top_pane.pack2(text_expander, resize=True, shrink=True) + if hide_progress: + self.progress_bar.hide() + self.ok_button = self.dialog.get_action_area().get_children()[0] + self.ok_button.hide() + for child in self.dialog.vbox.get_children(): + if isinstance(child, Gtk.HSeparator): + child.hide() + self.dialog.show() + + def run(self): + """Launch dialog in child process.""" + stdout = tempfile.TemporaryFile() + stderr = tempfile.TemporaryFile() + self.proc = Process( + target=_sep_process, args=[self.cmd_args, stdout, stderr] + ) + self.proc.start() + self.dialog.connect("destroy", self._handle_dialog_process_destroy) + while self.proc.is_alive(): + self.progress_bar.pulse() + if self.event_queue is not None: + while True: + try: + new_text = self.event_queue.get(False) + except queue.Empty: + break + end = self.text_buffer.get_end_iter() + self.text_buffer.insert_with_tags( + end, new_text, self.text_tag + ) + while Gtk.events_pending(): + Gtk.main_iteration() + time.sleep(0.1) + stdout.seek(0) + stderr.seek(0) + if self.proc.exitcode != 0: + if self._is_destroyed: + return self.proc.exitcode + else: + self.image.set_from_stock( + Gtk.STOCK_DIALOG_ERROR, Gtk.IconSize.DIALOG + ) + self.label.hide() + self.progress_bar.hide() + self.cmd_label.set_markup( + "" + + metomi.rose.gtk.util.safe_str(stderr.read()) + + "" + ) + self.ok_button.show() + for child in self.dialog.vbox.get_children(): + if isinstance(child, Gtk.HSeparator): + child.show() + self.dialog.run() + self.dialog.destroy() + return self.proc.exitcode + + def _handle_dialog_process_destroy(self, dialog): + if self.proc.is_alive(): + self.proc.terminate() + self._is_destroyed = True + return False + + def _handle_scroll_text_view(self, text_view, event=None): + """Scroll the parent scrolled window to the bottom.""" + vadj = text_view.get_parent().get_vadjustment() + if vadj.upper > vadj.lower + vadj.page_size: + vadj.set_value(vadj.upper - 0.95 * vadj.page_size) + + +def _sep_process(*args): + sys.exit(_process(*args)) + + +def _process(cmd_args, stdout=sys.stdout, stderr=sys.stderr): + if callable(cmd_args[0]): + func = cmd_args.pop(0) + try: + func(*cmd_args) + except Exception as exc: + stderr.write(type(exc).__name__ + ": " + str(exc) + "\n") + stderr.read() + return 1 + return 0 + proc = Popen(cmd_args, stdout=PIPE, stderr=PIPE) + for line in iter(proc.stdout.readline, ""): + stdout.write(line) + for line in iter(proc.stderr.readline, ""): + stderr.write(line) + proc.wait() + stdout.read() # Magically keep it alive!? + stderr.read() + return proc.poll() + + +def run_about_dialog( + name=None, + copyright_=None, + logo_path=None, + website=None, + website_label=None, +): + parent_window = get_dialog_parent() + about_dialog = Gtk.AboutDialog() + about_dialog.set_transient_for(parent_window) + about_dialog.set_program_name(name) + about_dialog.set_copyright(copyright_) + resource_loc = metomi.rose.resource.ResourceLocator(paths=sys.path) + logo_path = resource_loc.locate(logo_path) + about_dialog.set_logo(GdkPixbuf.Pixbuf.new_from_file(str(logo_path))) + about_dialog.set_website(website) + about_dialog.set_website_label(website_label) + about_dialog.set_comments(metomi.rose.config_editor.ABOUT_TEXT) + for credit in metomi.rose.config_editor.CREDIT: + about_dialog.add_credit_section(credit[0], credit[1]) + about_dialog.present() + + +def run_command_arg_dialog(cmd_name, help_text, run_hook): + """Launch a dialog to get extra arguments for a command.""" + checker_function = lambda t: True + dialog, container, name_entry = get_naming_dialog( + cmd_name, checker_function + ) + dialog.set_title(cmd_name) + help_label = Gtk.stock_lookup(Gtk.STOCK_HELP)[1].strip("_") + help_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_HELP, + label=help_label, + size=Gtk.IconSize.LARGE_TOOLBAR, + ) + help_button.connect( + "clicked", lambda b: run_scrolled_dialog(help_text, title=help_label) + ) + help_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + help_hbox.pack_start(help_button, expand=False, fill=False, padding=0) + help_hbox.show() + container.pack_end(help_hbox, expand=False, fill=False, padding=0) + name_entry.grab_focus() + dialog.connect( + "response", _handle_command_arg_response, run_hook, name_entry + ) + dialog.set_modal(False) + dialog.show() + + +def _handle_command_arg_response(dialog, response, run_hook, entry): + text = entry.get_text() + dialog.destroy() + if response == Gtk.ResponseType.ACCEPT: + run_hook(shlex.split(text)) + + +def run_dialog( + dialog_type, text, title=None, modal=True, cancel=False, extra_text=None +): + """Run a simple dialog with an 'OK' button and some text.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(parent=parent_window) + if parent_window is None: + dialog.set_icon(metomi.rose.gtk.util.get_icon()) + if cancel: + dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) + if extra_text: + info_button = Gtk.Button(stock=Gtk.STOCK_INFO) + info_button.show() + info_title = DIALOG_TITLE_EXTRA_INFO + info_button.connect( + "clicked", + lambda b: run_scrolled_dialog(extra_text, title=info_title), + ) + dialog.action_area.pack_start(info_button, expand=False, fill=False) + ok_button = dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) + if dialog_type == Gtk.MessageType.INFO: + stock_id = Gtk.STOCK_DIALOG_INFO + elif dialog_type == Gtk.MessageType.WARNING: + stock_id = Gtk.STOCK_DIALOG_WARNING + elif dialog_type == Gtk.MessageType.QUESTION: + stock_id = "dialog-question" + elif dialog_type == Gtk.MessageType.ERROR: + stock_id = Gtk.STOCK_DIALOG_ERROR + else: + stock_id = None + + if stock_id is not None: + dialog.image = Gtk.Image.new_from_stock(stock_id, Gtk.IconSize.DIALOG) + dialog.image.show() + + dialog.label = Gtk.Label(label=text) + try: + # could just keep set_text and set_markup? + Pango.parse_markup(text, len(text), "0") + except GLib.GError: + try: + dialog.label.set_markup(metomi.rose.gtk.util.safe_str(text)) + except Exception: + dialog.label.set_text(text) + else: + dialog.label.set_markup(text) + dialog.label.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + + if stock_id is not None: + image_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + image_vbox.pack_start( + dialog.image, expand=False, fill=False, padding=DIALOG_PADDING + ) + image_vbox.show() + hbox.pack_start( + image_vbox, + expand=False, + fill=False, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(0) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.pack_start(dialog.label, expand=True, fill=True, padding=0) + vbox.show() + scrolled_window.add_with_viewport(vbox) + scrolled_window.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + hbox.pack_start( + scrolled_window, + expand=True, + fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE, + ) + hbox.show() + dialog.vbox.pack_end(hbox, expand=True, fill=True, padding=0) + + if "\n" in text: + dialog.label.set_line_wrap(False) + dialog.set_resizable(True) + dialog.set_modal(modal) + if title is not None: + dialog.set_title(title) + + _configure_scroll(dialog, scrolled_window) + ok_button.grab_focus() + if modal or cancel: + dialog.show() + response = dialog.run() + dialog.destroy() + return response == Gtk.ResponseType.OK + else: + ok_button.connect("clicked", lambda b: dialog.destroy()) + dialog.show() + + +def run_exception_dialog(exception): + """Run a dialog displaying an exception.""" + text = type(exception).__name__ + ": " + str(exception) + return run_dialog(DIALOG_TYPE_ERROR, text, DIALOG_TITLE_ERROR) + + +def run_hyperlink_dialog( + stock_id=None, text="", title=None, search_func=lambda i: False +): + """Run a dialog with inserted hyperlinks.""" + parent_window = get_dialog_parent() + dialog = Gtk.Window() + dialog.set_transient_for(parent_window) + dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG) + dialog.set_title(title) + dialog.set_modal(False) + top_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + top_vbox.show() + main_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=DIALOG_PADDING + ) + main_hbox.show() + # Insert the image + image_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + image_vbox.show() + image = Gtk.Image.new_from_stock(stock_id, size=Gtk.IconSize.DIALOG) + image.show() + image_vbox.pack_start( + image, expand=False, fill=False, padding=DIALOG_PADDING + ) + main_hbox.pack_start( + image_vbox, expand=False, fill=False, padding=DIALOG_PADDING + ) + # Apply the text + message_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + message_vbox.show() + label = metomi.rose.gtk.util.get_hyperlink_label(text, search_func) + message_vbox.pack_start( + label, expand=True, fill=True, padding=DIALOG_PADDING + ) + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(DIALOG_PADDING) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + scrolled_window.add_with_viewport(message_vbox) + scrolled_window.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.pack_start(scrolled_window, expand=True, fill=True, padding=0) + vbox.show() + main_hbox.pack_start(vbox, expand=True, fill=True, padding=0) + top_vbox.pack_start(main_hbox, expand=True, fill=True, padding=0) + # Insert the button + button_box = Gtk.Box(spacing=DIALOG_PADDING) + button_box.show() + button = metomi.rose.gtk.util.CustomButton( + label=DIALOG_BUTTON_CLOSE, + size=Gtk.IconSize.LARGE_TOOLBAR, + stock_id=Gtk.STOCK_CLOSE, + ) + button.connect("clicked", lambda b: dialog.destroy()) + button_box.pack_end( + button, expand=False, fill=False, padding=DIALOG_PADDING + ) + top_vbox.pack_end( + button_box, expand=False, fill=False, padding=DIALOG_PADDING + ) + dialog.add(top_vbox) + if "\n" in text: + label.set_line_wrap(False) + dialog.set_resizable(True) + _configure_scroll(dialog, scrolled_window) + dialog.show() + label.set_selectable(True) + button.grab_focus() + + +def run_scrolled_dialog(text, title=None): + """Run a dialog intended for the display of a large amount of text.""" + parent_window = get_dialog_parent() + window = Gtk.Window() + window.set_transient_for(parent_window) + window.set_type_hint(Gdk.WindowTypeHint.DIALOG) + window.set_border_width(DIALOG_SUB_PADDING) + window.set_default_size(*DIALOG_SIZE_SCROLLED_MIN) + if title is not None: + window.set_title(title) + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.show() + label = Gtk.Label() + try: + Pango.parse_markup(text, len(text), "0") + except GLib.GError: + label.set_text(text) + else: + label.set_markup(text) + label.show() + filler_eb = Gtk.EventBox() + filler_eb.show() + label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + label_box.pack_start(label, expand=False, fill=False) + label_box.pack_start(filler_eb, expand=True, fill=True) + label_box.show() + width, height = label.size_request() + max_width, max_height = DIALOG_SIZE_SCROLLED_MAX + width = min([max_width, width]) + 2 * DIALOG_PADDING + height = min([max_height, height]) + 2 * DIALOG_PADDING + scrolled.add_with_viewport(label_box) + scrolled.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled.set_size_request(width, height) + button = Gtk.Button(stock=Gtk.STOCK_OK) + button.connect("clicked", lambda b: window.destroy()) + button.show() + button.grab_focus() + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + button_box.pack_end(button, expand=False, fill=False, padding=0) + button_box.show() + main_vbox = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=DIALOG_SUB_PADDING + ) + main_vbox.pack_start(scrolled, expand=True, fill=True, padding=0) + main_vbox.pack_end(button_box, expand=False, fill=False, padding=0) + main_vbox.show() + window.add(main_vbox) + window.show() + label.set_selectable(True) + return False + + +def get_naming_dialog(label, checker, ok_tip=None, err_tip=None): + """Return a dialog, container, and entry for entering a name.""" + button_list = ( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ) + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(buttons=button_list) + dialog.set_transient_for(parent_window) + dialog.set_modal(True) + ok_button = dialog.action_area.get_children()[0] + main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + name_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + name_label = Gtk.Label() + name_label.set_text(label) + name_label.show() + name_entry = Gtk.Entry() + name_entry.set_tooltip_text(ok_tip) + name_entry.connect( + "changed", _name_checker, checker, ok_button, ok_tip, err_tip + ) + name_entry.connect( + "activate", lambda b: dialog.response(Gtk.ResponseType.ACCEPT) + ) + name_entry.show() + name_hbox.pack_start( + name_label, expand=False, fill=False, padding=DIALOG_SUB_PADDING + ) + name_hbox.pack_start( + name_entry, expand=False, fill=True, padding=DIALOG_SUB_PADDING + ) + name_hbox.show() + main_vbox.pack_start( + name_hbox, expand=False, fill=True, padding=DIALOG_PADDING + ) + main_vbox.show() + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start(main_vbox, expand=False, fill=True, padding=DIALOG_PADDING) + hbox.show() + dialog.vbox.pack_start( + hbox, expand=False, fill=True, padding=DIALOG_PADDING + ) + return dialog, main_vbox, name_entry + + +def _name_checker(entry, checker, ok_button, ok_tip, err_tip): + good_colour = ok_button.style.text[Gtk.StateType.NORMAL] + bad_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR + ) + name = entry.get_text() + if checker(name): + entry.modify_text(Gtk.StateType.NORMAL, good_colour) + entry.set_tooltip_text(ok_tip) + ok_button.set_sensitive(True) + else: + entry.modify_text(Gtk.StateType.NORMAL, bad_colour) + entry.set_tooltip_text(err_tip) + ok_button.set_sensitive(False) + return False + + +def run_choices_dialog(text, choices, title=None): + """Run a dialog for choosing between a set of options.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog( + title, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ), + parent=parent_window, + ) + dialog.set_border_width(DIALOG_SUB_PADDING) + label = Gtk.Label() + try: + Pango.parse_markup(text, len(text), "0") + except GLib.GError: + label.set_text(text) + else: + label.set_markup(text) + dialog.vbox.set_spacing(DIALOG_SUB_PADDING) + dialog.vbox.pack_start(label, expand=False, fill=False, padding=0) + if len(choices) < 5: + for i, choice in enumerate(choices): + group = None + radio_button = Gtk.RadioButton( + group, label=choice, use_underline=False + ) + if i > 0: + group = radio_button + if i == 1: + radio_button.set_active(True) + dialog.vbox.pack_start( + radio_button, expand=False, fill=False, padding=0 + ) + getter = lambda: [ + b.get_label() for b in radio_button.get_group() if b.get_active() + ].pop() + else: + combo_box = Gtk.ComboBoxText() + for choice in choices: + combo_box.append_text(choice) + combo_box.set_active(0) + dialog.vbox.pack_start(combo_box, expand=False, fill=False, padding=0) + getter = lambda: choices[combo_box.get_active()] + dialog.show_all() + response = dialog.run() + if response == Gtk.ResponseType.ACCEPT: + choice = getter() + dialog.destroy() + return choice + dialog.destroy() + return None + + +def run_edit_dialog(text, finish_hook=None, title=None): + """Run a dialog for editing some text.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog( + title, + buttons=( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, + Gtk.ResponseType.ACCEPT, + ), + parent=parent_window, + ) + + dialog.set_border_width(DIALOG_SUB_PADDING) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(DIALOG_SUB_PADDING) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + + text_buffer = Gtk.TextBuffer() + text_buffer.set_text(text) + text_view = Gtk.TextView() + text_view.set_editable(True) + text_view.set_wrap_mode(Gtk.WrapMode.NONE) + text_view.set_buffer(text_buffer) + text_view.show() + + scrolled_window.add_with_viewport(text_view) + scrolled_window.show() + + dialog.vbox.pack_start(scrolled_window, expand=True, fill=True, padding=0) + get_text = lambda: text_buffer.get_text( + text_buffer.get_start_iter(), text_buffer.get_end_iter(), False + ) + + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + # defines the minimum acceptable size for the edit dialog + min_size = DIALOG_SIZE_PROCESS + + # hacky solution to get "true" size for dialog + dialog.show() + start_width = dialog.get_preferred_size().natural_size.width + start_height = dialog.get_preferred_size().natural_size.height + scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + end_width = dialog.get_preferred_size().natural_size.width + end_height = dialog.get_preferred_size().natural_size.height + my_size = ( + max([start_width, end_width, min_size[0]]) + 20, + max([start_height, end_height, min_size[1]]) + 20, + ) + new_size = [-1, -1] + for i in [0, 1]: + new_size[i] = min([my_size[i], max_size[i]]) + dialog.set_size_request(*new_size) + + if finish_hook is None: + response = dialog.run() + if response == Gtk.ResponseType.ACCEPT: + text = get_text().strip() + dialog.destroy() + return text + dialog.destroy() + else: + finish_func = lambda: finish_hook(get_text().strip()) + dialog.connect("response", _handle_edit_dialog_response, finish_func) + dialog.show() + + +def _handle_edit_dialog_response(dialog, response, finish_hook): + if response == Gtk.ResponseType.ACCEPT: + finish_hook() + dialog.destroy() + + +def get_dialog_parent(): + """Find the currently active window, if any, and reparent dialog.""" + ok_windows = [] + max_size = -1 + # not sure if this window change is correct + for window in Gtk.Window.list_toplevels(): + if window.get_title() is not None and window.get_toplevel() == window: + ok_windows.append(window) + size_proxy = window.get_size()[0] * window.get_size()[1] + if size_proxy > max_size: + max_size = size_proxy + for window in ok_windows: + if window.is_active(): + return window + for window in ok_windows: + if window.get_size()[0] * window.get_size()[1] == max_size: + return window + + +def set_exception_hook_dialog(keep_alive=False): + """Set a dialog to run once an uncaught exception occurs.""" + prev_hook = sys.excepthook + sys.excepthook = lambda c, i, t: _run_exception_dialog( + c, i, t, prev_hook, keep_alive + ) + + +def _configure_scroll(dialog, scrolled_window): + """Set scroll window size and scroll policy.""" + # make sure the dialog size doesn't exceed the maximum - if so change it + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + my_size = dialog.get_size() + new_size = [-1, -1] + for i, scrollbar_cls in [ + (0, Gtk.Scrollbar.new(orientation=Gtk.Orientation.VERTICAL)), + (1, Gtk.Scrollbar.new(orientation=Gtk.Orientation.HORIZONTAL)), + ]: + new_size[i] = min(my_size[i], max_size[i]) + if new_size[i] < max_size[i]: + # Factor in existence of a scrollbar in the other dimension. + # For horizontal dimension, add width of vertical scroll bar + 2 + # For vertical dimension, add height of horizontal scroll bar + 2 + # What is the value in rose 2019? - here it is zero on load. + new_size[i] += ( + getattr( + scrollbar_cls.get_preferred_size().natural_size, + ["width", "height"][i], + ) + + 2 + ) + scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC + ) + dialog.set_default_size(*new_size) + + +def _run_exception_dialog(exc_class, exc_inst, tback, hook, keep_alive): + # Handle an uncaught exception. + if exc_class == KeyboardInterrupt: + return False + hook(exc_class, exc_inst, tback) + program_name = metomi.rose.resource.ResourceLocator().get_util_name() + tback_text = metomi.rose.gtk.util.safe_str( + "".join(traceback.format_tb(tback)) + ) + shutdown_text = DIALOG_TEXT_SHUTTING_DOWN + if keep_alive: + shutdown_text = DIALOG_TEXT_SHUTDOWN_ASAP + text = DIALOG_TEXT_UNCAUGHT_EXCEPTION.format( + program_name, shutdown_text, exc_class.__name__, exc_inst, tback_text + ) + run_dialog(DIALOG_TYPE_ERROR, text, title=DIALOG_TITLE_UNCAUGHT_EXCEPTION) + if not keep_alive: + try: + Gtk.main_quit() + except RuntimeError: + pass diff --git a/metomi/rose/gtk/splash.py b/metomi/rose/gtk/splash.py new file mode 100755 index 0000000000..df24aeceec --- /dev/null +++ b/metomi/rose/gtk/splash.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- +"""Invoke a splash screen from the command line.""" + +import json +import os +from subprocess import Popen, PIPE +import sys +import threading +import time + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +from gi.repository import GObject +from gi.repository import Pango + +import metomi.rose.gtk.util +import metomi.rose.popen + + +class SplashScreen(Gtk.Window): + """Run a splash screen that receives update information.""" + + BACKGROUND_COLOUR = "white" # Same as logo background. + PADDING = 10 + SUB_PADDING = 5 + FONT_DESC = "8" + PULSE_FRACTION = 0.05 + TIME_WAIT_FINISH = 500 # Milliseconds. + TIME_IDLE_BEFORE_PULSE = 3000 # Milliseconds. + TIME_INTERVAL_PULSE = 50 # Milliseconds. + + def __init__(self, logo_path, title, total_number_of_events): + super(SplashScreen, self).__init__() + self.set_title(title) + self.set_decorated(False) + self.stopped = False + self.set_icon(metomi.rose.gtk.util.get_icon()) + self.modify_bg( + Gtk.StateType.NORMAL, + metomi.rose.gtk.util.color_parse(self.BACKGROUND_COLOUR), + ) + self.set_gravity(5) # same as gravity center + self.set_position(Gtk.WindowPosition.CENTER) + main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_vbox.show() + image = Gtk.Image.new_from_file(logo_path) + image.show() + image_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + image_hbox.show() + image_hbox.pack_start(image, expand=False, fill=True, padding=0) + main_vbox.pack_start(image_hbox, expand=False, fill=True, padding=0) + self._is_progress_bar_pulsing = False + self._progress_fraction = 0.0 + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_pulse_step(self.PULSE_FRACTION) + self.progress_bar.show() + self.progress_bar.modify_font(Pango.FontDescription(self.FONT_DESC)) + self.progress_bar.set_ellipsize(Pango.EllipsizeMode.END) + self._progress_message = None + self.event_count = 0.0 + self.total_number_of_events = float(total_number_of_events) + progress_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=self.SUB_PADDING + ) + progress_hbox.show() + progress_hbox.pack_start( + self.progress_bar, expand=True, fill=True, padding=self.SUB_PADDING + ) + main_vbox.pack_start( + progress_hbox, expand=False, fill=False, padding=self.PADDING + ) + self.add(main_vbox) + if self.total_number_of_events > 0: + self.show() + while Gtk.events_pending(): + Gtk.main_iteration() + + def update(self, event, no_progress=False, new_total_events=None): + """Show text corresponding to an event.""" + text = str(event) + if new_total_events is not None: + self.total_number_of_events = new_total_events + self.event_count = 0.0 + + if not no_progress: + self.event_count += 1.0 + + if self.total_number_of_events == 0: + fraction = 1.0 + else: + fraction = min( + [1.0, self.event_count / self.total_number_of_events] + ) + self._stop_pulse() + if not no_progress: + GObject.idle_add(self.progress_bar.set_fraction, fraction) + self._progress_fraction = fraction + self.progress_bar.set_text(text) + self._progress_message = text + GObject.timeout_add( + self.TIME_IDLE_BEFORE_PULSE, self._start_pulse, fraction, text + ) + if fraction == 1.0 and not no_progress: + GObject.timeout_add(self.TIME_WAIT_FINISH, self.finish) + while Gtk.events_pending(): + Gtk.main_iteration() + + def _start_pulse(self, idle_fraction, idle_message): + """Start the progress bar pulsing (moving side-to-side).""" + if ( + self._progress_message != idle_message + or self._progress_fraction != idle_fraction + ): + return False + self._is_progress_bar_pulsing = True + GObject.timeout_add(self.TIME_INTERVAL_PULSE, self._pulse) + return False + + def _stop_pulse(self): + self._is_progress_bar_pulsing = False + + def _pulse(self): + if self._is_progress_bar_pulsing: + self.progress_bar.pulse() + while Gtk.events_pending(): + Gtk.main_iteration() + return self._is_progress_bar_pulsing + + def finish(self): + """Delete the splash screen.""" + self.stopped = True + GObject.idle_add(self.destroy) + return False + + +class NullSplashScreenProcess(object): + """Implement a null interface similar to SplashScreenProcess.""" + + def __init__(self, *args): + pass + + def update(self, *args, **kwargs): + pass + + def start(self): + pass + + def stop(self): + pass + + +class SplashScreenProcess(object): + """Run a separate process that launches a splash screen. + + Communicate via the update method. + + """ + + def __init__(self, *args): + args = [str(a) for a in args] + self.args = args + self._buffer = [] + self._last_buffer_output_time = time.time() + self.start() + + def update(self, *args, **kwargs): + """Communicate via stdin to SplashScreenManager. + + args and kwargs are the update method args, kwargs. + + """ + if self.process is None: + self.start() + if kwargs.get("no_progress"): + return self._update_buffered(*args, **kwargs) + self._flush_buffer() + json_text = json.dumps({"args": args, "kwargs": kwargs}) + self._communicate(json_text) + + def _communicate(self, json_text): + while True: + try: + self.process.stdin.write((json_text + "\n").encode()) + except IOError: + self.start() + self.process.stdin.write((json_text + "\n").encode()) + else: + break + + def _flush_buffer(self): + if self._buffer: + self._communicate(self._buffer[-1]) + del self._buffer[:] + + def _update_buffered(self, *args, **kwargs): + tinit = time.time() + json_text = json.dumps({"args": args, "kwargs": kwargs}) + if tinit - self._last_buffer_output_time > 0.02: + self._communicate(json_text) + del self._buffer[:] + self._last_buffer_output_time = tinit + else: + self._buffer.append(json_text) + + __call__ = update + + def start(self): + self.process = Popen( + ["rose", "launch-splash-screen"] + list(self.args), stdin=PIPE + ) + + def stop(self): + self.process.kill() + self.process = None + + +class SplashScreenUpdaterThread(threading.Thread): + """Update a splash screen using info from the stdin file object.""" + + def __init__(self, window, stop_event, stdin): + super(SplashScreenUpdaterThread, self).__init__() + self.window = window + self.stop_event = stop_event + self.stdin = stdin + + def run(self): + """Loop over time and wait for stdin lines.""" + GObject.timeout_add(1000, self._check_splash_screen_alive) + while not self.stop_event.is_set(): + time.sleep(0.005) + if self.stop_event.is_set(): + return False + try: + stdin_line = self.stdin.readline() + if not stdin_line: + continue + except IOError: + continue + try: + update_input = json.loads(stdin_line.strip()) + except ValueError: + continue + if update_input == "stop": + self.stop_event.set() + continue + GObject.idle_add(self._update_splash_screen, update_input) + + def stop(self): + try: + Gtk.main_quit() + except RuntimeError: + # This can result from gtk having already quit. + pass + + def _check_splash_screen_alive(self): + """Check whether the splash screen is finished.""" + if self.window.stopped or self.stop_event.is_set(): + self._stop() + return False + return True + + def _update_splash_screen(self, update_input): + """Update the splash screen with info extracted from stdin.""" + self.window.update(*update_input["args"], **update_input["kwargs"]) + return False + + +def main(argv=sys.argv): + """Start splash screen.""" + sys.path.append(os.getenv("ROSE_HOME")) + splash_screen = SplashScreen(argv[0], argv[1], argv[2]) + stop_event = threading.Event() + update_thread = SplashScreenUpdaterThread( + splash_screen, stop_event, sys.stdin + ) + update_thread.start() + try: + Gtk.main() + except KeyboardInterrupt: + pass + finally: + update_thread.join() + + +if __name__ == "__main__": + main() diff --git a/metomi/rose/gtk/util.py b/metomi/rose/gtk/util.py new file mode 100644 index 0000000000..ec2d2e6ca6 --- /dev/null +++ b/metomi/rose/gtk/util.py @@ -0,0 +1,754 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Rose 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Rose. If not, see . +# ----------------------------------------------------------------------------- + +import multiprocessing +import queue +import re +import sys +import threading +import webbrowser + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GdkPixbuf +from gi.repository import GObject +from gi.repository import GLib +from gi.repository import Pango + +import metomi.rose.reporter +import metomi.rose.resource + + +REC_HYPERLINK_ID_OR_URL = re.compile( + r"""(?P\b) + (?P[\w:-]+=\w+|https?://[^\s<]+) + (?P\b)""", + re.X, +) +MARKUP_URL_HTML = ( + r"""\g""" + + r"""\g""" + + r"""\g""" +) +MARKUP_URL_UNDERLINE = ( + r"""\g""" + r"""\g""" + r"""\g""" +) + + +class ColourParseError(ValueError): + """An exception raised when gtk colour parsing fails.""" + + def __str__(self): + return "unable to parse colour specification: %s" % self.args[0] + + +class CustomButton(Gtk.Button): + """Returns a custom Gtk.Button.""" + + def __init__( + self, + label=None, + stock_id=None, + size=Gtk.IconSize.SMALL_TOOLBAR, + tip_text=None, + as_tool=False, + icon_at_start=False, + has_menu=False, + ): + self.hbox = Gtk.Box() + self.size = size + self.as_tool = as_tool + self.icon_at_start = icon_at_start + if label is not None: + self.label = Gtk.Label() + self.label.set_text(label) + self.label.show() + + if self.icon_at_start: + self.hbox.pack_end( + self.label, expand=False, fill=False, padding=5 + ) + else: + self.hbox.pack_start( + self.label, expand=False, fill=False, padding=5 + ) + if stock_id is not None: + self.stock_id = stock_id + self.icon = Gtk.Image() + if stock_id.startswith("gtk") or stock_id.startswith("rose-gtk"): + self.icon.set_from_stock(stock_id, size) + else: + self.icon.set_from_icon_name(stock_id, size) + self.icon.show() + if self.icon_at_start: + self.hbox.pack_start( + self.icon, expand=False, fill=False, padding=0 + ) + else: + self.hbox.pack_end( + self.icon, expand=False, fill=False, padding=0 + ) + if has_menu: + # not sure if this is correct + arrow = Gtk.Image.new_from_icon_name("pan-down-symbolic", size) + arrow.show() + self.hbox.pack_end(arrow, expand=False, fill=False, padding=0) + self.hbox.reorder_child(arrow, 0) + self.hbox.show() + super(CustomButton, self).__init__() + if self.as_tool: + self.set_relief(Gtk.ReliefStyle.NONE) + self.connect("leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE)) + self.add(self.hbox) + self.show() + if tip_text is not None: + self.set_tooltip_text(tip_text) + + def set_stock_id(self, stock_id): + """Set an icon based on the stock id.""" + if hasattr(self, "icon"): + self.hbox.remove(self.icon) + self.icon.set_from_stock(stock_id, self.size) + self.stock_id = stock_id + if self.icon_at_start: + self.hbox.pack_start( + self.icon, expand=False, fill=False, padding=0 + ) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False, padding=0) + return False + + def set_tip_text(self, new_text): + """Set new tooltip text.""" + self.set_tooltip_text(new_text) + + def position_menu(self, menu, widget): + """Place a drop-down menu carefully below the button.""" + xpos, ypos = widget.get_window().get_origin() + allocated_rectangle = widget.get_allocation() + xpos += allocated_rectangle.x + ypos += allocated_rectangle.y + allocated_rectangle.height + return xpos, ypos, False + + +class CustomExpandButton(Gtk.Button): + """Custom button for expanding/hiding something""" + + def __init__( + self, + expander_function=None, + label=None, + size=Gtk.IconSize.SMALL_TOOLBAR, + tip_text=None, + as_tool=False, + icon_at_start=False, + minimised=True, + ): + + self.expander_function = expander_function + self.minimised = minimised + + self.expand_id = Gtk.STOCK_ADD + self.minimise_id = Gtk.STOCK_REMOVE + + if minimised: + self.stock_id = self.expand_id + else: + self.stock_id = self.minimise_id + + self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.size = size + self.as_tool = as_tool + self.icon_at_start = icon_at_start + + if label is not None: + self.label = Gtk.Label() + self.label.set_text(label) + self.label.show() + + if self.icon_at_start: + self.hbox.pack_end( + self.label, expand=False, fill=False, padding=5 + ) + else: + self.hbox.pack_start( + self.label, expand=False, fill=False, padding=5 + ) + self.icon = Gtk.Image() + self.icon.set_from_stock(self.stock_id, size) + self.icon.show() + if self.icon_at_start: + self.hbox.pack_start( + self.icon, expand=False, fill=False, padding=0 + ) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False, padding=0) + self.hbox.show() + super(CustomExpandButton, self).__init__() + + if self.as_tool: + self.set_relief(Gtk.ReliefStyle.NONE) + self.connect("leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE)) + self.add(self.hbox) + self.show() + if tip_text is not None: + self.set_tooltip_text(tip_text) + self.connect("clicked", self.toggle) + + def set_stock_id(self, stock_id): + """Set the icon stock_id""" + if hasattr(self, "icon"): + self.hbox.remove(self.icon) + self.icon.set_from_stock(stock_id, self.size) + self.stock_id = stock_id + if self.icon_at_start: + self.hbox.pack_start( + self.icon, expand=False, fill=False, padding=0 + ) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False, padding=0) + return False + + def set_tip_text(self, new_text): + """Set the tip text""" + self.set_tooltip_text(new_text) + + def toggle(self, minimise=None): + """Toggle between show/hide states""" + if minimise is not None: + if minimise == self.minimised: + return + self.minimised = not self.minimised + if self.minimised: + self.stock_id = self.expand_id + else: + self.stock_id = self.minimise_id + if self.expander_function is not None: + self.expander_function(set_visibility=not self.minimised) + self.set_stock_id(self.stock_id) + + +class CustomMenuButton(Gtk.MenuToolButton): + """Custom wrapper for the gtk Menu Tool Button.""" + + def __init__( + self, + label=None, + stock_id=None, + size=Gtk.IconSize.SMALL_TOOLBAR, + tip_text=None, + menu_items=[], + menu_funcs=[], + ): + if stock_id is not None: + self.stock_id = stock_id + self.icon = Gtk.Image() + self.icon.set_from_stock(stock_id, size) + self.icon.show() + super().__init__(self, self.icon, label) + self.set_tooltip_text(tip_text) + self.show() + button_menu = Gtk.Menu() + for item_tuple, func in zip(menu_items, menu_funcs): + name = item_tuple[0] + if len(item_tuple) == 1: + new_item = Gtk.MenuItem(name) + else: + new_item_box = Gtk.Box() + new_item_icon = Gtk.Image.new_from_icon_name( + item_tuple[1], Gtk.IconSize.MENU + ) + new_item_label = Gtk.Label(label=name) + new_item = Gtk.MenuItem() + new_item_box.pack_start(new_item_icon, False, False, 0) + new_item_box.pack_start(new_item_label, False, False, 0) + Gtk.Container.add(new_item, new_item_box) + new_item._func = func + new_item.connect("activate", lambda m: m._func()) + new_item.show() + button_menu.append(new_item) + button_menu.show() + self.set_menu(button_menu) + + +class ToolBar(Gtk.Toolbar): + """An easier-to-use Gtk.Toolbar.""" + + def __init__(self, widgets=[], sep_on_name=[]): + super(ToolBar, self).__init__() + self.item_dict = {} + self.show() + widgets.reverse() + for name, stock in widgets: + if name in sep_on_name: + separator = Gtk.SeparatorToolItem() + separator.show() + self.insert(separator, 0) + if isinstance(stock, str) and stock.startswith("Gtk."): + stock = getattr(Gtk, stock.replace("Gtk.", "", 1)) + if callable(stock): + widget = stock() + widget.show() + widget.set_tooltip_text(name) + else: + widget = CustomButton( + stock_id=stock, tip_text=name, as_tool=True + ) + icon_tool_item = Gtk.ToolItem() + icon_tool_item.add(widget) + icon_tool_item.show() + self.item_dict[name] = { + "tip": name, + "widget": widget, + "func": None, + } + self.insert(icon_tool_item, 0) + + def set_widget_function(self, name, function, args=[]): + self.item_dict[name]["widget"].args = args + if len(args) > 0: + self.item_dict[name]["widget"].connect( + "clicked", lambda b: function(*b.args) + ) + else: + self.item_dict[name]["widget"].connect( + "clicked", lambda b: function() + ) + + def set_widget_sensitive(self, name, is_sensitive): + self.item_dict[name]["widget"].set_sensitive(is_sensitive) + + +class AsyncStatusbar(Gtk.Statusbar): + """Wrapper class to add polling a file to statusbar API.""" + + def __init__(self, *args): + super(AsyncStatusbar, self).__init__(*args) + self.show() + self.queue = multiprocessing.Queue() + self.ctx_id = self.get_context_id("_all") + self.should_stop = False + self.connect("destroy", self._handle_destroy) + GObject.timeout_add(1000, self._poll) + + def _handle_destroy(self, *args): + self.should_stop = True + + def _poll(self): + self.update() + return not self.should_stop + + def update(self): + try: + message = self.queue.get(block=False) + except queue.Empty: + pass + else: + self.push(self.ctx_id, message) + + def put(self, message, instant=False): + if instant: + self.push(self.ctx_id, message) + else: + self.queue.put_nowait(message) + self.update() + + +class AsyncLabel(Gtk.Label): + """Wrapper class to add polling a file to label API.""" + + def __init__(self, *args): + super(AsyncLabel, self).__init__(*args) + self.show() + self.queue = multiprocessing.Queue() + self.should_stop = False + self.connect("destroy", self._handle_destroy) + GObject.timeout_add(1000, self._poll) + + def _handle_destroy(self, *args): + self.should_stop = True + + def _poll(self): + self.update() + return not self.should_stop + + def update(self): + try: + message = self.queue.get(block=False) + except queue.Empty: + pass + else: + self.set_text(message) + + def put(self, message, instant=False): + if instant: + self.set_text(message) + else: + self.queue.put_nowait(message) + self.update() + + +class ThreadedProgressBar(Gtk.ProgressBar): + """Wrapper class to allow threaded progress bar pulsing.""" + + def __init__(self, *args, **kwargs): + super(ThreadedProgressBar, self).__init__(*args, **kwargs) + self.set_fraction(0.0) + self.set_pulse_step(0.1) + + def start_pulsing(self): + self.stop = False + self.show() + self.thread = threading.Thread() + self.thread.run = lambda: GObject.timeout_add(50, self._run) + self.thread.start() + + def _run(self): + Gdk.threads_enter() + self.pulse() + if self.stop: + self.set_fraction(1.0) + while Gtk.events_pending(): + Gtk.main_iteration() + Gdk.threads_leave() + return not self.stop + + def stop_pulsing(self): + self.stop = True + self.thread.join() + GObject.idle_add(self.hide) + + +class Notebook(Gtk.Notebook): + """Wrapper class to improve the Gtk.Notebook API.""" + + def __init__(self, *args): + super(Notebook, self).__init__(*args) + self.set_scrollable(True) + self.show() + + def get_pages(self): + """Return all 'page' container widgets.""" + pages = [] + for i in range(self.get_n_pages()): + pages.append(self.get_nth_page(i)) + return pages + + def get_page_labels(self): + """Return all first pieces of text found in page labelwidgets.""" + labels = [] + for i in range(self.get_n_pages()): + nth_page = self.get_nth_page(i) + widgets = [self.get_tab_label(nth_page)] + while not hasattr(widgets[0], "get_text"): + if hasattr(widgets[0], "get_children"): + widgets.extend(widgets[0].get_children()) + elif hasattr(widgets[0], "get_child"): + widgets.append(widgets[0].get_child()) + widgets.pop(0) + labels.append(widgets[0].get_text()) + return labels + + def get_page_ids(self): + """Return the namespace id attributes for all notebook pages.""" + ids = [] + for i in range(self.get_n_pages()): + nth_page = self.get_nth_page(i) + if hasattr(nth_page, "namespace"): + ids.append(nth_page.namespace) + return ids + + def delete_by_label(self, label): + """Remove the (unique) page with this label as title.""" + self.remove_page(self.get_page_labels().index(label)) + + def delete_by_id(self, page_id): + """Use this only with pages with the attribute 'namespace'.""" + self.remove_page(self.get_page_ids().index(page_id)) + + def set_tab_label_packing(self, page, tab_labelwidget): + super(Notebook, self).set_tab_label(page, tab_labelwidget) + + +class TooltipTreeView(Gtk.TreeView): + """Wrapper class for Gtk.TreeView with a better tooltip API. + + It takes two keyword arguments, model as in Gtk.TreeView and + get_tooltip_func which is analogous to the 'query-tooltip' + connector in Gtk.TreeView. + + This should be overridden either at or after initialisation. + It takes four arguments - the Gtk.TreeView, a Gtk.TreeIter and + a column index for the Gtk.TreeView, and a Gtk.ToolTip. + + Return True to display the ToolTip, or False to hide it. + + """ + + def __init__( + self, model=None, get_tooltip_func=None, multiple_selection=False + ): + super(TooltipTreeView, self).__init__(model) + self.get_tooltip = get_tooltip_func + self.set_has_tooltip(True) + self._last_tooltip_path = None + self._last_tooltip_column = None + self.connect("query-tooltip", self._handle_tooltip) + if multiple_selection: + self.set_rubber_banding(True) + self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + + def _handle_tooltip(self, view, xpos, ypos, kbd_ctx, tip): + """Handle creating a tooltip for the treeview.""" + xpos, ypos = view.convert_widget_to_bin_window_coords(xpos, ypos) + pathinfo = view.get_path_at_pos(xpos, ypos) + if pathinfo is None: + return False + path, column = pathinfo[:2] + if path is None: + return False + if ( + path != self._last_tooltip_path + or column != self._last_tooltip_column + ): + self._last_tooltip_path = path + self._last_tooltip_column = column + return False + col_index = view.get_columns().index(column) + row_iter = view.get_model().get_iter(path) + if self.get_tooltip is None: + return False + return self.get_tooltip(view, row_iter, col_index, tip) + + +class TreeModelSortUtil(object): + """This class contains useful sorting methods for TreeModelSort. + + Arguments: + sort_model_getter_func - a function accepting no arguments that + returns the TreeModelSort. This is necessary if a combination + of TreeModelFilter and TreeModelSort is used. + + Keyword Arguments: + multi_sort_num - the maximum number of columns to sort by. For + example, setting this to 2 means that a single secondary sort + may be applied based on the previous sort column. + + You must connect to both handle_sort_column_change and sort_column + for multi-column sorting. Example code: + + sort_model = Gtk.TreeModelSort(filter_model) + sort_util = TreeModelSortUtil( + lambda: sort_model, + multi_sort_num=2) + for i in range(len(columns)): + sort_model.set_sort_func(i, sort_util.sort_column, i) + sort_model.connect("sort-column-changed", + sort_util.handle_sort_column_change) + + """ + + def __init__(self, sort_model_getter_func, multi_sort_num=1): + self._get_sort_model = sort_model_getter_func + self.multi_sort_num = multi_sort_num + self._sort_columns_stored = [] + + def clear_sort_columns(self): + """Clear any multi-sort information.""" + self._sort_columns_stored = [] + + def cmp_(self, value1, value2): + """Perform a useful form of 'cmp'""" + # Hack - some values coming in as None so replace with strings + if value1 is None: + value1 = "None" + if value2 is None: + value2 = "None" + if isinstance(value1, str) and isinstance(value2, str): + if value1.isdigit() and value2.isdigit(): + return (float(value1) > float(value2)) - ( + float(value1) < float(value2) + ) + return metomi.rose.config.sort_settings(value1, value2) + return (value1 > value2) - (value1 < value2) + + def handle_sort_column_change(self, model): + """Store previous sorting information for multi-column sorts.""" + id_, order = model.get_sort_column_id() + if id_ is None and order is None: + return False + if ( + self._sort_columns_stored + and self._sort_columns_stored[0][0] == id_ + ): + self._sort_columns_stored.pop(0) + self._sort_columns_stored.insert(0, (id_, order)) + if len(self._sort_columns_stored) > 2: + self._sort_columns_stored.pop() + + def sort_column(self, model, iter1, iter2, col_index): + """Multi-column sort.""" + val1 = model.get_value(iter1, col_index) + val2 = model.get_value(iter2, col_index) + rval = self.cmp_(val1, val2) + # If rval is 1 or -1, no need for a multi-column sort. + if rval == 0: + if isinstance(model, Gtk.TreeModelSort): + this_order = model.get_sort_column_id()[1] + else: + this_order = self._get_sort_model().get_sort_column_id()[1] + cmp_factor = 1 + if this_order == Gtk.SortType.DESCENDING: + # We need to de-invert the sort order for multi sorting. + cmp_factor = -1 + i = 0 + while rval == 0 and i < len(self._sort_columns_stored): + next_id, next_order = self._sort_columns_stored[i] + if next_id == col_index: + i += 1 + continue + next_cmp_factor = cmp_factor * 1 + if next_order == Gtk.SortType.DESCENDING: + # Set the correct order for multi sorting. + next_cmp_factor = cmp_factor * -1 + val1 = model.get_value(iter1, next_id) + val2 = model.get_value(iter2, next_id) + rval = next_cmp_factor * self.cmp_(val1, val2) + i += 1 + return rval + + +def color_parse(color_specification): + """Wrap Gdk.color_parse and report errors with the specification.""" + try: + return Gdk.color_parse(color_specification) + except ValueError: + metomi.rose.reporter.Reporter().report( + ColourParseError(color_specification) + ) + # Return a noticeable colour. + return Gdk.color_parse("#0000FF") # Blue + + +def get_hyperlink_label(text, search_func=lambda i: False): + """Return a label with clickable hyperlinks.""" + label = Gtk.Label() + label.show() + try: + Pango.parse_markup(text, len(text), "0") + except GLib.GError: + label.set_text(text) + else: + label.connect( + "activate-link", lambda _, u: handle_link(u, search_func) + ) + text = REC_HYPERLINK_ID_OR_URL.sub(MARKUP_URL_HTML, text) + label.set_markup(text) + return label + + +def get_icon(system="rose"): + """Return a GdkPixbuf.Pixbuf for the system icon.""" + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + icon_path = locator.locate("etc/images/{0}-icon-trim.svg".format(system)) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)) + except Exception: + icon_path = locator.locate( + "etc/images/{0}-icon-trim.png".format(system) + ) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)) + return pixbuf + + +def handle_link(url, search_function, handle_web=False): + if url.startswith("http"): + if handle_web: + webbrowser.open(url) + else: + search_function(url) + return False + + +def extract_link(label, search_function): + text = label.get_text() + bounds = label.get_selection_bounds() + if not bounds: + return None + lower_bound, upper_bound = bounds + while lower_bound > 0: + if text[lower_bound - 1].isspace(): + break + lower_bound -= 1 + while upper_bound < len(text): + if text[upper_bound].isspace(): + break + upper_bound += 1 + link = text[lower_bound:upper_bound] + if any(c.isspace() for c in link): + return None + handle_link(link, search_function, handle_web=True) + + +def rc_setup(rc_resource): + """Run Gtk.rc_parse on the resource, to setup the gtk settings.""" + Gtk.rc_parse(rc_resource) + + +def setup_scheduler_icon(ipath=None): + """Setup a 'stock' icon for the scheduler""" + theme = Gtk.IconTheme.get_default() + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + # iname = "rose-gtk-scheduler" + if ipath is None: + theme.load_icon("image-missing", 64, 0) + else: + path = locator.locate(ipath) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(path)) + theme.set_icon(pixbuf) # not sure if this is right! + + +def setup_stock_icons(): + """Setup any additional 'stock' icons.""" + new_icon_factory = Gtk.IconFactory() + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + for png_icon_name in [ + "gnome_add", + "gnome_add_errors", + "gnome_add_warnings", + "gnome_package_system", + "gnome_package_system_errors", + "gnome_package_system_warnings", + ]: + ifile = png_icon_name + ".png" + istring = png_icon_name.replace("_", "-") + path = locator.locate("etc/images/rose-config-edit/" + ifile) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(path)) + new_icon_factory.add("rose-gtk-" + istring, Gtk.IconSet(pixbuf)) + exp_icon_pixbuf = get_icon() + new_icon_factory.add("rose-exp-logo", Gtk.IconSet(exp_icon_pixbuf)) + new_icon_factory.add_default() + + +def safe_str(value): + """Formats a value safely for use in pango markup.""" + string = str(value).replace("&", "&") + return string.replace(">", ">").replace("<", "<") diff --git a/metomi/rose/macro.py b/metomi/rose/macro.py index 9cd8722e12..a09d5bd409 100644 --- a/metomi/rose/macro.py +++ b/metomi/rose/macro.py @@ -399,11 +399,10 @@ def _get_config_sections(self, config_data): if "" not in sections: sections.append("") else: - for key in set( - config_data["sections"].keys() - + config_data["variables"].keys() - ): - sections.append(key) + sections = list(set( + list(config_data["sections"].keys()) + + list(config_data["variables"].keys()) + )) return sections def _get_config_section_options(self, config_data, section): diff --git a/metomi/rose/reporter.py b/metomi/rose/reporter.py index b8d1efcc45..ce5efe8969 100644 --- a/metomi/rose/reporter.py +++ b/metomi/rose/reporter.py @@ -16,6 +16,8 @@ # ----------------------------------------------------------------------------- """Reporter for diagnostic messages.""" +import queue +import multiprocessing import doctest import sys @@ -136,8 +138,8 @@ def report(self, message, kind=None, level=None, prefix=None, clip=None): if isinstance(message, bytes): message = message.decode() if callable(self.event_handler): - return self.event_handler(message, kind, level, prefix, clip) - + ret = self.event_handler(message, kind, level, prefix, clip) + return ret if isinstance(message, Event): if kind is None: kind = message.kind @@ -266,8 +268,57 @@ def _tty_colour_err(self, str_): return str_ -class Event: +class ReporterContextQueue(ReporterContext): + """A context for the reporter object. + + It has the following attributes: + kind: + The message kind to report to this context. + (Reporter.KIND_ERR, Reporter.KIND_ERR or None.) + verbosity: + The verbosity of this context. + queue: + The multiprocessing.Queue. + prefix: + The default message prefix (str or callable). + """ + + def __init__(self, + kind=None, + verbosity=Reporter.DEFAULT, + queue=None, + prefix=None): + ReporterContext.__init__(self, kind, verbosity, None, prefix) + if queue is None: + queue = multiprocessing.Manager().Queue() + self.queue = queue + self.closed = False + self._messages_pending = [] + + def close(self): + self._send_pending_messages() + self.closed = True + + def is_closed(self): + return self.closed + + def write(self, message): + self._messages_pending.append(message) + self._send_pending_messages() + + def _send_pending_messages(self): + while self._messages_pending: + message = self._messages_pending[0] + try: + self.queue.put(message, block=False) + except queue.Full: + break + else: + del self._messages_pending[0] + + +class Event: """A base class for events suitable for feeding into a Reporter.""" VV = Reporter.VV diff --git a/metomi/rose/resource.py b/metomi/rose/resource.py index e6dc5c2ea7..c8451147dd 100644 --- a/metomi/rose/resource.py +++ b/metomi/rose/resource.py @@ -18,7 +18,7 @@ Convenient functions for searching resource files. """ -from importlib.machinery import SourceFileLoader +import importlib import inspect import os from pathlib import Path @@ -32,9 +32,10 @@ ERROR_LOCATE_OBJECT = "Could not locate {0}" +MODULES = {} -class ResourceError(Exception): +class ResourceError(Exception): """A named resource not found.""" def __init__(self, key): @@ -189,11 +190,9 @@ def import_object( is_builtin = False module_name = ".".join(import_string.split(".")[:-1]) if module_name.startswith("rose."): + module_name = "metomi." + module_name + if module_name.startswith("metomi.rose."): is_builtin = True - if module_prefix is None: - as_name = module_name - else: - as_name = module_prefix + module_name class_name = import_string.split(".")[-1] module_fpath = "/".join(import_string.rsplit(".")[:-1]) + ".py" if module_fpath == ".py": @@ -212,7 +211,19 @@ def import_object( for filename in module_files: sys.path.insert(0, os.path.dirname(filename)) try: - module = SourceFileLoader(as_name, filename).load_module() + spec = ( + importlib.util.spec_from_file_location(filename, filename) + ) + if filename not in MODULES: + spec = ( + importlib.util.spec_from_file_location(filename, filename) # noqa: E501 + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + MODULES[filename] = module + else: + module = MODULES[filename] + sys.path.pop(0) except ImportError as exc: error_handler(exc) sys.path.pop(0) diff --git a/metomi/rose/rose.py b/metomi/rose/rose.py index 85460443c9..d60c16fc3d 100644 --- a/metomi/rose/rose.py +++ b/metomi/rose/rose.py @@ -95,16 +95,6 @@ def iter_entry_points(name: str): # (ns, sub_cmd): message ('rosa', 'rpmbuild'): 'Rosa RPM Builder has been removed.', - ('rose', 'config-edit'): ( - 'The Rose configuration editor has been removed. The old ' - 'Rose 2019 GUI remains compatible with Rose 2 configurations.' - ), - ('rose', 'edit'): ( - 'The Rose configuration editor has been removed. The old ' - 'Rose 2019 GUI remains compatible with Rose 2 configurations.' - ), - ('rose', 'metadata-graph'): - 'This command has been removed pending re-implementation', ('rose', 'suite-clean'): 'This command has been replaced by: "cylc clean".', ('rose', 'suite-cmp-vc'): @@ -148,7 +138,9 @@ def iter_entry_points(name: str): ('rosie', 'co'): ('rosie', 'checkout'), ('rosie', 'copy'): - ('rosie', 'create') + ('rosie', 'create'), + ('rose', 'edit'): + ('rose', 'config-edit') } # fmt: on diff --git a/setup.cfg b/setup.cfg index 608e65b5f7..4d02be22e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,6 +79,8 @@ docs = graph = pygraphviz>1.0,!=1.8 rosa = +rose-edit = + pygobject>=3.48.2 tests = aiosmtpd pytest @@ -95,6 +97,7 @@ all = %(docs)s %(graph)s %(rosa)s + %(rose-edit)s %(tests)s [options.entry_points] @@ -110,10 +113,12 @@ rose.commands = config = metomi.rose.config_cli:main config-diff = metomi.rose.config_diff:main config-dump = metomi.rose.config_dump:main + config-edit = metomi.rose.config_editor.main:main date = metomi.rose.date_cli:main env-cat = metomi.rose.env_cat:main host-select = metomi.rose.host_select:main host-select-client = metomi.rose.host_select_client:main + launch-splash-screen = metomi.rose.gtk.splash:main macro = metomi.rose.macro:main metadata-check = metomi.rose.metadata_check:main metadata-gen = metomi.rose.metadata_gen:main diff --git a/sphinx/api/command-reference.rst b/sphinx/api/command-reference.rst index 2b479a7dbb..b80c9db349 100644 --- a/sphinx/api/command-reference.rst +++ b/sphinx/api/command-reference.rst @@ -16,12 +16,11 @@ Rose Commands rose config-edit ^^^^^^^^^^^^^^^^ -.. warning:: - - The Rose Edit GUI has not yet been reimplemented in Rose 2. +.. code-block:: bash - The old Rose 2019 (Python 2) GUI remains compatible with Rose 2 - configurations. + rose config-edit + # or simply + rose edit .. _command-rose-suite-run: diff --git a/sphinx/api/configuration/metadata.rst b/sphinx/api/configuration/metadata.rst index ce587956d2..6423f33370 100644 --- a/sphinx/api/configuration/metadata.rst +++ b/sphinx/api/configuration/metadata.rst @@ -772,7 +772,7 @@ The metadata options for a configuration fall into four categories: .. code-block:: rose - widget[rose-config-edit]=rose.config_editor.valuewidget.radiobuttons.RadioButtonsValueWidget + widget[rose-config-edit]=metomi.rose.config_editor.valuewidget.radiobuttons.RadioButtonsValueWidget Another useful Rose built-in widget to use is the array element aligning page widget, @@ -793,7 +793,7 @@ The metadata options for a configuration fall into four categories: .. code-block:: rose [namelist:meal_choices] - widget[rose-config-edit]=rose.config_editor.pagewidget.table.PageArrayTable + widget[rose-config-edit]=metomi.rose.config_editor.pagewidget.table.PageArrayTable to align the elements on the page like this: