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: