/*
 * Copyright 2019 faddenSoft
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Input;
using Microsoft.Win32;

using Asm65;
using CommonUtil;
using CommonWPF;
using SourceGen.Sandbox;
using SourceGen.WpfGui;
using System.Windows.Media;

namespace SourceGen {
    /// <summary>
    /// This class manages user interaction.  The goal is for this to be relatively
    /// GUI-toolkit-agnostic, with all the WPF stuff tucked into the code-behind files.  An
    /// instance of this class is created by MainWindow when the app starts.
    /// 
    /// There is some Windows-specific stuff, like MessageBox and OpenFileDialog.
    /// </summary>
    public class MainController {
        private const string SETTINGS_FILE_NAME = "SourceGen-settings";

        #region Project state

        // Currently open project, or null if none.
        private DisasmProject mProject;

        // Pathname to 65xx data file.
        private string mDataPathName;

        // Pathname of .dis65 file.  This will be empty for a new project.
        private string mProjectPathName;

        /// <summary>
        /// Data backing the code list.
        /// </summary>
        public LineListGen CodeLineList { get; private set; }

        #endregion Project state


        /// <summary>
        /// Reference back to MainWindow object.
        /// </summary>
        private MainWindow mMainWin;

        /// <summary>
        /// Hex dump viewer window.  This is used for the currently open project.
        /// </summary>
        private Tools.WpfGui.HexDumpViewer mHexDumpDialog;

        // Debug windows.
        private Tools.WpfGui.ShowText mShowAnalysisTimersDialog;
        public bool IsDebugAnalysisTimersOpen { get { return mShowAnalysisTimersDialog != null; } }
        private Tools.WpfGui.ShowText mShowAnalyzerOutputDialog;
        public bool IsDebugAnalyzerOutputOpen { get { return mShowAnalyzerOutputDialog != null; } }
        private Tools.WpfGui.ShowText mShowUndoRedoHistoryDialog;
        public bool IsDebugUndoRedoHistoryOpen { get { return mShowUndoRedoHistoryDialog != null; } }

        /// <summary>
        /// This holds any un-owned Windows that we don't otherwise track.  It's used for
        /// hex dump windows of arbitrary files.  We need to close them when the main window
        /// is closed.
        /// </summary>
        private List<Window> mUnownedWindows = new List<Window>();

        /// <summary>
        /// ASCII chart reference window.  Not tied to the project.
        /// </summary>
        private Tools.WpfGui.AsciiChart mAsciiChartDialog;

        /// <summary>
        /// Returns true if the ASCII chart window is currently open.
        /// </summary>
        public bool IsAsciiChartOpen { get { return mAsciiChartDialog != null; } }

        /// <summary>
        /// Instruction chart reference window.  Not tied to the project.
        /// </summary>
        private Tools.WpfGui.InstructionChart mInstructionChartDialog;

        /// <summary>
        /// Returns true if the instruction chart window is currently open.
        /// </summary>
        public bool IsInstructionChartOpen { get { return mInstructionChartDialog != null; } }

        /// <summary>
        /// List of recently-opened projects.
        /// </summary>
        public List<string> RecentProjectPaths = new List<string>(MAX_RECENT_PROJECTS);
        public const int MAX_RECENT_PROJECTS = 6;

        /// <summary>
        /// Analyzed selection state, updated whenever the selection changes.
        /// </summary>
        public SelectionState SelectionAnalysis { get; set; }

        /// <summary>
        /// Activity log generated by the code and data analyzers.  Displayed in window.
        /// </summary>
        private DebugLog mGenerationLog;

        /// <summary>
        /// Timing data generated during analysis.
        /// </summary>
        TaskTimer mReanalysisTimer = new TaskTimer();

        /// <summary>
        /// Stack for navigate forward/backward.
        /// </summary>
        private NavStack mNavStack = new NavStack();

        /// <summary>
        /// Output format configuration.
        /// </summary>
        private Formatter.FormatConfig mFormatterConfig;

        /// <summary>
        /// Output format controller.
        /// 
        /// This is shared with the DisplayList.
        /// </summary>
        private Formatter mFormatter;

        /// <summary>
        /// Pseudo-op names.
        /// 
        /// This is shared with the DisplayList.
        /// </summary>
        private PseudoOp.PseudoOpNames mPseudoOpNames;

        /// <summary>
        /// String we most recently searched for.
        /// </summary>
        private string mFindString = string.Empty;

        /// <summary>
        /// Initial start point of most recent search.
        /// </summary>
        private int mFindStartIndex = -1;

        /// <summary>
        /// True if previous search was backward, so we can tell if we changed direction
        /// (otherwise we think we immediately wrapped around and the search stops).
        /// </summary>
        private bool mFindBackward = false;

        /// <summary>
        /// Used to highlight the line that is the target of the selected line.
        /// </summary>
        private int mTargetHighlightIndex = -1;

        /// <summary>
        /// Code list color scheme.
        /// </summary>
        private MainWindow.ColorScheme mColorScheme = MainWindow.ColorScheme.Light;

        /// <summary>
        /// CPU definition used when the Formatter was created.  If the CPU choice or
        /// inclusion of undocumented opcodes changes, we need to wipe the formatter.
        /// </summary>
        private CpuDef mFormatterCpuDef;

        /// <summary>
        /// Instruction description object.  Used for Info window.
        /// </summary>
        private OpDescription mOpDesc = OpDescription.GetOpDescription(null);

        /// <summary>
        /// If true, plugins will execute in the main application's AppDomain instead of
        /// the sandbox.
        /// </summary>
        private bool mUseMainAppDomainForPlugins = false;

        /// <summary>
        /// Code list column numbers.
        /// </summary>
        public enum CodeListColumn {
            Offset = 0, Address, Bytes, Flags, Attributes, Label, Opcode, Operand, Comment,
            COUNT       // must be last; must equal number of columns
        }

        /// <summary>
        /// Clipboard format enumeration.
        /// </summary>
        public enum ClipLineFormat {
            Unknown = -1,
            AssemblerSource = 0,
            Disassembly = 1,
            AllColumns = 2
        }

        /// <summary>
        /// True if a project is open and AnalyzeUncategorizedData is enabled.
        /// </summary>
        public bool IsAnalyzeUncategorizedDataEnabled {
            get {
                if (mProject == null) {
                    return false;
                }
                return mProject.ProjectProps.AnalysisParams.AnalyzeUncategorizedData;
            }
        }


        #region Init and settings

        public MainController(MainWindow win) {
            mMainWin = win;

            ScriptManager.UseKeepAliveHack = true;
        }

        /// <summary>
        /// Early initialization, before the window is visible.  Notably, we want to get the
        /// window placement data, so we can position and size the window before it's first
        /// drawn (avoids a blink).
        /// </summary>
        public void WindowSourceInitialized() {
            // Load the settings from the file.  If this fails we have no way to tell the user,
            // so just keep going.
            LoadAppSettings();
            SetAppWindowLocation();
        }

        /// <summary>
        /// Perform one-time initialization after the Window has finished loading.  We defer
        /// to this point so we can report fatal errors directly to the user.
        /// </summary>
        public void WindowLoaded() {
            Debug.Assert(CommonUtil.RangeSet.Test());
            Debug.Assert(CommonUtil.TypedRangeSet.Test());
            Debug.Assert(CommonUtil.Version.Test());

            if (RuntimeDataAccess.GetDirectory() == null) {
                MessageBox.Show(Res.Strings.RUNTIME_DIR_NOT_FOUND,
                    Res.Strings.RUNTIME_DIR_NOT_FOUND_CAPTION,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                Application.Current.Shutdown();
                return;
            }
            try {
                PluginDllCache.PreparePluginDir();
            } catch (Exception ex) {
                string pluginPath = PluginDllCache.GetPluginDirPath();
                if (pluginPath == null) {
                    pluginPath = "<???>";
                }
                string msg = string.Format(Res.Strings.PLUGIN_DIR_FAIL_FMT,
                    pluginPath + ": " + ex.Message);
                MessageBox.Show(msg, Res.Strings.PLUGIN_DIR_FAIL_CAPTION,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                Application.Current.Shutdown();
                return;
            }

            // Place the main window and apply the various settings.
            ApplyAppSettings();

            UpdateTitle();
            mMainWin.UpdateRecentLinks();

            ProcessCommandLine();

            // Create an initial value.
            SelectionAnalysis = UpdateSelectionState();
        }

        private void ProcessCommandLine() {
            string[] args = Environment.GetCommandLineArgs();
            if (args.Length == 2) {
                DoOpenFile(Path.GetFullPath(args[1]));
            }
        }


        /// <summary>
        /// Loads settings from the settings file into AppSettings.Global.  Does not apply
        /// them to the ProjectView.
        /// </summary>
        private void LoadAppSettings() {
            AppSettings settings = AppSettings.Global;

            // Set some default settings for first-time use.  The general rule is to set
            // a default value of false, 0, or the empty string, so we only need to set
            // values here when that isn't the case.  The point at which the setting is
            // actually used is expected to do something reasonable by default.

            settings.SetBool(AppSettings.SYMWIN_SHOW_USER, true);
            settings.SetBool(AppSettings.SYMWIN_SHOW_NON_UNIQUE, false);
            settings.SetBool(AppSettings.SYMWIN_SHOW_PROJECT, true);
            settings.SetBool(AppSettings.SYMWIN_SHOW_PLATFORM, false);
            settings.SetBool(AppSettings.SYMWIN_SHOW_AUTO, false);
            settings.SetBool(AppSettings.SYMWIN_SHOW_ADDR, true);
            settings.SetBool(AppSettings.SYMWIN_SHOW_CONST, true);
            settings.SetBool(AppSettings.SYMWIN_SORT_ASCENDING, true);
            settings.SetInt(AppSettings.SYMWIN_SORT_COL, (int)Symbol.SymbolSortField.Name);

            settings.SetBool(AppSettings.FMT_UPPER_OPERAND_A, true);
            settings.SetBool(AppSettings.FMT_UPPER_OPERAND_S, true);
            settings.SetBool(AppSettings.FMT_ADD_SPACE_FULL_COMMENT, true);
            settings.SetBool(AppSettings.FMT_SPACES_BETWEEN_BYTES, true);
            settings.SetString(AppSettings.FMT_OPCODE_SUFFIX_LONG, "l");
            settings.SetString(AppSettings.FMT_OPERAND_PREFIX_ABS, "a:");
            settings.SetString(AppSettings.FMT_OPERAND_PREFIX_LONG, "f:");

            settings.SetBool(AppSettings.SRCGEN_ADD_IDENT_COMMENT, true);
            settings.SetBool(AppSettings.SRCGEN_LONG_LABEL_NEW_LINE, true);

#if DEBUG
            settings.SetBool(AppSettings.DEBUG_MENU_ENABLED, true);
#else
            settings.SetBool(AppSettings.DEBUG_MENU_ENABLED, false);
#endif

            // Make sure we have entries for these.
            settings.SetString(AppSettings.CDLV_FONT_FAMILY,
                mMainWin.CodeListFontFamily.ToString());
            settings.SetInt(AppSettings.CDLV_FONT_SIZE, (int)mMainWin.CodeListFontSize);

            // Character and string delimiters.
            Formatter.DelimiterSet chrDel = Formatter.DelimiterSet.GetDefaultCharDelimiters();
            string chrSer = chrDel.Serialize();
            settings.SetString(AppSettings.FMT_CHAR_DELIM, chrSer);

            Formatter.DelimiterSet strDel = Formatter.DelimiterSet.GetDefaultStringDelimiters();
            string strSer = strDel.Serialize();
            settings.SetString(AppSettings.FMT_STRING_DELIM, strSer);


            // Load the settings file, and merge it into the globals.
            string runtimeDataDir = RuntimeDataAccess.GetDirectory();
            if (runtimeDataDir == null) {
                Debug.WriteLine("Unable to load settings file");
                return;
            }
            string settingsDir = Path.GetDirectoryName(runtimeDataDir);
            string settingsPath = Path.Combine(settingsDir, SETTINGS_FILE_NAME);
            try {
                string text = File.ReadAllText(settingsPath);
                AppSettings fileSettings = AppSettings.Deserialize(text);
                AppSettings.Global.MergeSettings(fileSettings);
                Debug.WriteLine("Settings file loaded and merged");
            } catch (Exception ex) {
                Debug.WriteLine("Unable to read settings file: " + ex.Message);
            }
        }

        /// <summary>
        /// Saves AppSettings to a file.
        /// </summary>
        private void SaveAppSettings() {
            if (!AppSettings.Global.Dirty) {
                Debug.WriteLine("Settings not dirty, not saving");
                return;
            }

            // Main window position and size.
            AppSettings.Global.SetString(AppSettings.MAIN_WINDOW_PLACEMENT,
                mMainWin.GetPlacement());

            // Horizontal splitters.
            AppSettings.Global.SetInt(AppSettings.MAIN_LEFT_PANEL_WIDTH,
                (int)mMainWin.LeftPanelWidth);
            AppSettings.Global.SetInt(AppSettings.MAIN_RIGHT_PANEL_WIDTH,
                (int)mMainWin.RightPanelWidth);

            // Vertical splitters.
            //AppSettings.Global.SetInt(AppSettings.MAIN_REFERENCES_HEIGHT,
            //    (int)mMainWin.ReferencesPanelHeight);
            //AppSettings.Global.SetInt(AppSettings.MAIN_SYMBOLS_HEIGHT,
            //    (int)mMainWin.SymbolsPanelHeight);

            // Something peculiar happens when we switch from the launch window to the
            // code list: the refs/notes splitter and sym/info splitter shift down a pixel.
            // Closing the project causes everything to shift back.  I'm not sure what's
            // causing the layout to change.  I'm working around the issue by not saving the
            // splitter positions if they've only moved 1 pixel.
            // TODO: fix this properly
            int refSetting = AppSettings.Global.GetInt(AppSettings.MAIN_REFERENCES_HEIGHT, -1);
            if ((int)mMainWin.ReferencesPanelHeight == refSetting ||
                    (int)mMainWin.ReferencesPanelHeight == refSetting - 1) {
                Debug.WriteLine("NOT updating references height");
            } else {
                AppSettings.Global.SetInt(AppSettings.MAIN_REFERENCES_HEIGHT,
                    (int)mMainWin.ReferencesPanelHeight);
            }
            int symSetting = AppSettings.Global.GetInt(AppSettings.MAIN_SYMBOLS_HEIGHT, -1);
            if ((int)mMainWin.SymbolsPanelHeight == symSetting ||
                    (int)mMainWin.SymbolsPanelHeight == symSetting - 1) {
                Debug.WriteLine("NOT updating symbols height");
            } else {
                AppSettings.Global.SetInt(AppSettings.MAIN_SYMBOLS_HEIGHT,
                    (int)mMainWin.SymbolsPanelHeight);
            }

            mMainWin.CaptureColumnWidths();

            string runtimeDataDir = RuntimeDataAccess.GetDirectory();
            if (runtimeDataDir == null) {
                Debug.WriteLine("Unable to save settings file");
                return;
            }
            string settingsDir = Path.GetDirectoryName(runtimeDataDir);
            string settingsPath = Path.Combine(settingsDir, SETTINGS_FILE_NAME);
            try {
                string cereal = AppSettings.Global.Serialize();
                File.WriteAllText(settingsPath, cereal);
                AppSettings.Global.Dirty = false;
                Debug.WriteLine("Saved settings (" + settingsPath + ")");
            } catch (Exception ex) {
                Debug.WriteLine("Failed to save settings: " + ex.Message);
            }
        }

        /// <summary>
        /// Replaces the contents of the global settings object with the new settings,
        /// then applies them to the project.
        /// </summary>
        /// <param name="settings">New settings.</param>
        public void SetAppSettings(AppSettings settings) {
            AppSettings.Global.ReplaceSettings(settings);
            ApplyAppSettings();

            // We get called whenever Apply or OK is hit in the settings editor, so it's
            // a pretty good time to save the settings out.
            SaveAppSettings();
        }

        /// <summary>
        /// Sets the app window's location and size.  This should be called before the window has
        /// finished initialization.
        /// </summary>
        private void SetAppWindowLocation() {
            const int DEFAULT_SPLIT = 250;

            AppSettings settings = AppSettings.Global;

            string placement = settings.GetString(AppSettings.MAIN_WINDOW_PLACEMENT, null);
            if (placement != null) {
                mMainWin.SetPlacement(placement);
            }

            mMainWin.LeftPanelWidth =
                settings.GetInt(AppSettings.MAIN_LEFT_PANEL_WIDTH, DEFAULT_SPLIT);
            mMainWin.RightPanelWidth =
                settings.GetInt(AppSettings.MAIN_RIGHT_PANEL_WIDTH, DEFAULT_SPLIT);
            mMainWin.ReferencesPanelHeight =
                settings.GetInt(AppSettings.MAIN_REFERENCES_HEIGHT, 350);
            mMainWin.SymbolsPanelHeight =
                settings.GetInt(AppSettings.MAIN_SYMBOLS_HEIGHT, 400);

            mMainWin.RestoreColumnWidths();
        }

        /// <summary>
        /// Applies "actionable" settings to the ProjectView, pulling them out of the global
        /// settings object.  If a project is open, refreshes the display list and all sub-windows.
        /// </summary>
        private void ApplyAppSettings() {
            Debug.WriteLine("ApplyAppSettings...");
            AppSettings settings = AppSettings.Global;

            // Set up the formatter.
            mFormatterConfig = new Formatter.FormatConfig();
            AsmGen.GenCommon.ConfigureFormatterFromSettings(AppSettings.Global,
                ref mFormatterConfig);
            mFormatterConfig.mEndOfLineCommentDelimiter = ";";
            mFormatterConfig.mFullLineCommentDelimiterBase = ";";
            mFormatterConfig.mBoxLineCommentDelimiter = string.Empty;

            mFormatterConfig.mNonUniqueLabelPrefix =
                settings.GetString(AppSettings.FMT_NON_UNIQUE_LABEL_PREFIX, string.Empty);
            mFormatterConfig.mLocalVariableLabelPrefix =
                settings.GetString(AppSettings.FMT_LOCAL_VARIABLE_PREFIX, string.Empty);
            mFormatterConfig.mCommaSeparatedDense =
                settings.GetBool(AppSettings.FMT_COMMA_SEP_BULK_DATA, false);

            string chrDelCereal = settings.GetString(AppSettings.FMT_CHAR_DELIM, null);
            if (chrDelCereal != null) {
                mFormatterConfig.mCharDelimiters =
                    Formatter.DelimiterSet.Deserialize(chrDelCereal);
            }
            string strDelCereal = settings.GetString(AppSettings.FMT_STRING_DELIM, null);
            if (strDelCereal != null) {
                mFormatterConfig.mStringDelimiters =
                    Formatter.DelimiterSet.Deserialize(strDelCereal);
            }


            // Update the formatter, and null out mFormatterCpuDef to force a refresh
            // of related items.
            mFormatter = new Formatter(mFormatterConfig);
            mFormatterCpuDef = null;

            // Set pseudo-op names.  Entries aren't allowed to be blank, so we start with the
            // default values and merge in whatever the user has configured.
            mPseudoOpNames = PseudoOp.DefaultPseudoOpNames;
            string pseudoCereal = settings.GetString(AppSettings.FMT_PSEUDO_OP_NAMES, null);
            if (!string.IsNullOrEmpty(pseudoCereal)) {
                PseudoOp.PseudoOpNames deser = PseudoOp.PseudoOpNames.Deserialize(pseudoCereal);
                if (deser != null) {
                    mPseudoOpNames = PseudoOp.PseudoOpNames.Merge(mPseudoOpNames, deser);
                }
            }

            // Configure the Symbols window.
            mMainWin.SymFilterUserLabels =
                settings.GetBool(AppSettings.SYMWIN_SHOW_USER, false);
            mMainWin.SymFilterNonUniqueLabels =
                settings.GetBool(AppSettings.SYMWIN_SHOW_NON_UNIQUE, false);
            mMainWin.SymFilterAutoLabels =
                settings.GetBool(AppSettings.SYMWIN_SHOW_AUTO, false);
            mMainWin.SymFilterProjectSymbols =
                settings.GetBool(AppSettings.SYMWIN_SHOW_PROJECT, false);
            mMainWin.SymFilterPlatformSymbols =
                settings.GetBool(AppSettings.SYMWIN_SHOW_PLATFORM, false);
            mMainWin.SymFilterConstants =
                settings.GetBool(AppSettings.SYMWIN_SHOW_CONST, false);
            mMainWin.SymFilterAddresses =
                settings.GetBool(AppSettings.SYMWIN_SHOW_ADDR, false);

            // Get the configured font info.  If nothing is configured, use whatever the
            // code list happens to be using now.
            string fontFamilyName = settings.GetString(AppSettings.CDLV_FONT_FAMILY, null);
            if (fontFamilyName == null) {
                fontFamilyName = mMainWin.CodeListFontFamily.ToString();
            }
            int size = settings.GetInt(AppSettings.CDLV_FONT_SIZE, -1);
            if (size <= 0) {
                size = (int)mMainWin.CodeListFontSize;
            }

            mMainWin.SetCodeListFont(fontFamilyName, size);

            // Update the column widths.  This was done earlier during init, but may need to be
            // repeated if the show/hide buttons were used in Settings.
            mMainWin.RestoreColumnWidths();

            // Unpack the recent-project list.
            UnpackRecentProjectList();

            // Set the color scheme.
            bool useDark = settings.GetBool(AppSettings.SKIN_DARK_COLOR_SCHEME, false);
            if (useDark) {
                mColorScheme = MainWindow.ColorScheme.Dark;
            } else {
                mColorScheme = MainWindow.ColorScheme.Light;
            }
            mMainWin.SetColorScheme(mColorScheme);
            if (CodeLineList != null) {
                SetCodeLineListColorMultiplier();
            }

            // Enable the DEBUG menu if configured.
            mMainWin.ShowDebugMenu =
                AppSettings.Global.GetBool(AppSettings.DEBUG_MENU_ENABLED, false);

            // Finally, update the display list generator with all the fancy settings.
            if (CodeLineList != null) {
                // Regenerate the display list with the latest formatter config and
                // pseudo-op definition.  (These are set as part of the refresh.)
                UndoableChange uc =
                    UndoableChange.CreateDummyChange(UndoableChange.ReanalysisScope.DisplayOnly);
                ApplyChanges(new ChangeSet(uc), false);
            }
        }

        private void SetCodeLineListColorMultiplier() {
            if (mColorScheme == MainWindow.ColorScheme.Dark) {
                CodeLineList.NoteColorMultiplier = 0.6f;
            } else {
                CodeLineList.NoteColorMultiplier = 1.0f;
            }
        }

        private void UnpackRecentProjectList() {
            RecentProjectPaths.Clear();

            string cereal = AppSettings.Global.GetString(
                AppSettings.PRVW_RECENT_PROJECT_LIST, null);
            if (string.IsNullOrEmpty(cereal)) {
                return;
            }

            try {
                JavaScriptSerializer ser = new JavaScriptSerializer();
                RecentProjectPaths = ser.Deserialize<List<string>>(cereal);
            } catch (Exception ex) {
                Debug.WriteLine("Failed deserializing recent projects: " + ex.Message);
                return;
            }
        }

        /// <summary>
        /// Ensures that the named project is at the top of the list.  If it's elsewhere
        /// in the list, move it to the top.  Excess items are removed.
        /// </summary>
        /// <param name="projectPath"></param>
        private void UpdateRecentProjectList(string projectPath) {
            if (string.IsNullOrEmpty(projectPath)) {
                // This can happen if you create a new project, then close the window
                // without having saved it.
                return;
            }
            int index = RecentProjectPaths.IndexOf(projectPath);
            if (index == 0) {
                // Already in the list, nothing changes.  No need to update anything else.
                return;
            }
            if (index > 0) {
                RecentProjectPaths.RemoveAt(index);
            }
            RecentProjectPaths.Insert(0, projectPath);

            // Trim the list to the max allowed.
            while (RecentProjectPaths.Count > MAX_RECENT_PROJECTS) {
                Debug.WriteLine("Recent projects: dropping " +
                    RecentProjectPaths[MAX_RECENT_PROJECTS]);
                RecentProjectPaths.RemoveAt(MAX_RECENT_PROJECTS);
            }

            // Store updated list in app settings.  JSON-in-JSON is ugly and inefficient,
            // but it'll do for now.
            JavaScriptSerializer ser = new JavaScriptSerializer();
            string cereal = ser.Serialize(RecentProjectPaths);
            AppSettings.Global.SetString(AppSettings.PRVW_RECENT_PROJECT_LIST, cereal);

            mMainWin.UpdateRecentLinks();
        }

        /// <summary>
        /// Updates the main form title to show project name and modification status.
        /// </summary>
        private void UpdateTitle() {
            // Update main window title.
            StringBuilder sb = new StringBuilder();
            if (mProject != null) {
                if (string.IsNullOrEmpty(mProjectPathName)) {
                    sb.Append(Res.Strings.TITLE_NEW_PROJECT);
                } else {
                    sb.Append(Path.GetFileName(mProjectPathName));
                }
                if (mProject.IsReadOnly) {
                    sb.Append(" ");
                    sb.Append(Res.Strings.TITLE_READ_ONLY);
                }
                sb.Append(" - ");
            }

            sb.Append(Res.Strings.TITLE_BASE);

            if (mProject != null && mProject.IsDirty) {
                sb.Append(" - ");
                sb.Append(Res.Strings.TITLE_MODIFIED);
            }
            mMainWin.Title = sb.ToString();

            UpdateByteCounts();
        }

        /// <summary>
        /// Updates the code/data/junk percentages in the status bar.
        /// </summary>
        private void UpdateByteCounts() {
            if (mProject == null) {
                mMainWin.ByteCountText = string.Empty;
                return;
            }

            Debug.Assert(mProject.ByteCounts.CodeByteCount + mProject.ByteCounts.DataByteCount +
                mProject.ByteCounts.JunkByteCount == mProject.FileData.Length);

            int total = mProject.FileData.Length;
            float codePerc = (mProject.ByteCounts.CodeByteCount * 100.0f) / total;
            float dataPerc = (mProject.ByteCounts.DataByteCount * 100.0f) / total;
            float junkPerc = (mProject.ByteCounts.JunkByteCount * 100.0f) / total;
            mMainWin.ByteCountText = string.Format(Res.Strings.STATUS_BYTE_COUNT_FMT,
                total / 1024.0f, codePerc, dataPerc, junkPerc);
        }

        #endregion Init and settings


        #region Project management

        private bool PrepareNewProject(string dataPathName, SystemDef sysDef) {
            DisasmProject proj = new DisasmProject();
            mDataPathName = dataPathName;
            mProjectPathName = string.Empty;
            byte[] fileData = null;
            try {
                fileData = LoadDataFile(dataPathName);
            } catch (Exception ex) {
                Debug.WriteLine("PrepareNewProject exception: " + ex);
                string message = Res.Strings.OPEN_DATA_FAIL_CAPTION;
                string caption = Res.Strings.OPEN_DATA_FAIL_MESSAGE + ": " + ex.Message;
                MessageBox.Show(caption, message, MessageBoxButton.OK,
                    MessageBoxImage.Error);
                return false;
            }
            proj.UseMainAppDomainForPlugins = mUseMainAppDomainForPlugins;
            proj.Initialize(fileData.Length);
            proj.PrepForNew(fileData, Path.GetFileName(dataPathName));

            // Initial header comment is the program name and version.
            string cmt = string.Format(Res.Strings.DEFAULT_HEADER_COMMENT_FMT, App.ProgramVersion);
            proj.LongComments.Add(LineListGen.Line.HEADER_COMMENT_OFFSET,
                new MultiLineComment(cmt));

            // The system definition provides a set of defaults that can be overridden.
            // We pull everything of interest out and then discard the object.
            proj.ApplySystemDef(sysDef);

            mProject = proj;

            return true;
        }

#if false
        private class FinishPrepProgress : WorkProgress.IWorker {
            public string ExtMessages { get; private set; }
            private MainController mMainCtrl;

            public FinishPrepProgress(MainController mainCtrl) {
                mMainCtrl = mainCtrl;
            }
            public object DoWork(BackgroundWorker worker) {
                string messages = mMainCtrl.mProject.LoadExternalFiles();
                mMainCtrl.DoRefreshProject(UndoableChange.ReanalysisScope.CodeAndData);
                return messages;
            }

            public void RunWorkerCompleted(object results) {
                ExtMessages = (string)results;
            }
        }
#endif

        private void FinishPrep() {
            CodeLineList = new LineListGen(mProject, mMainWin.CodeDisplayList,
                mFormatter, mPseudoOpNames);
            SetCodeLineListColorMultiplier();

            string messages = mProject.LoadExternalFiles();
            if (messages.Length != 0) {
                // ProjectLoadIssues isn't quite the right dialog, but it'll do.  This is
                // purely informative; no decision needs to be made.
                ProjectLoadIssues dlg = new ProjectLoadIssues(mMainWin, messages,
                    ProjectLoadIssues.Buttons.Continue);
                dlg.ShowDialog();
            }

            // Ideally we'd call DoRefreshProject (and LoadExternalFiles) from a progress
            // dialog, but we're not allowed to update the DisplayList from a different thread.
            RefreshProject(UndoableChange.ReanalysisScope.CodeAndData);

            // Populate the Symbols list.
            PopulateSymbolsList();

            // Load initial contents of Notes panel.
            PopulateNotesList();

            mMainWin.ShowCodeListView = true;
            mNavStack.Clear();

            UpdateRecentProjectList(mProjectPathName);

            UpdateTitle();
        }

        /// <summary>
        /// Loads the data file, reading it entirely into memory.
        /// 
        /// All errors are reported as exceptions.
        /// </summary>
        /// <param name="dataFileName">Full pathname.</param>
        /// <returns>Data file contents.</returns>
        private static byte[] LoadDataFile(string dataFileName) {
            byte[] fileData;

            using (FileStream fs = File.Open(dataFileName, FileMode.Open, FileAccess.Read)) {
                // Check length; should have been caught earlier.
                if (fs.Length > DisasmProject.MAX_DATA_FILE_SIZE) {
                    throw new InvalidDataException(
                        string.Format(Res.Strings.OPEN_DATA_TOO_LARGE_FMT,
                            fs.Length / 1024, DisasmProject.MAX_DATA_FILE_SIZE / 1024));
                } else if (fs.Length == 0) {
                    throw new InvalidDataException(Res.Strings.OPEN_DATA_EMPTY);
                }
                fileData = new byte[fs.Length];
                int actual = fs.Read(fileData, 0, (int)fs.Length);
                if (actual != fs.Length) {
                    // Not expected -- should be able to read the entire file in one shot.
                    throw new Exception(Res.Strings.OPEN_DATA_PARTIAL_READ);
                }
            }

            return fileData;
        }

        /// <summary>
        /// Applies the changes to the project, adds them to the undo stack, and updates
        /// the display.
        /// </summary>
        /// <param name="cs">Set of changes to apply.</param>
        private void ApplyUndoableChanges(ChangeSet cs) {
            if (cs.Count == 0) {
                Debug.WriteLine("ApplyUndoableChanges: change set is empty");
            }
            ApplyChanges(cs, false);
            mProject.PushChangeSet(cs);
            UpdateTitle();

            // If the debug dialog is visible, update it.
            if (mShowUndoRedoHistoryDialog != null) {
                mShowUndoRedoHistoryDialog.DisplayText = mProject.DebugGetUndoRedoHistory();
            }
        }

        /// <summary>
        /// Applies the changes to the project, and updates the display.
        /// 
        /// This is called by the undo/redo commands.  Don't call this directly from the
        /// various UI-driven functions, as this does not add the change to the undo stack.
        /// </summary>
        /// <param name="cs">Set of changes to apply.</param>
        /// <param name="backward">If set, undo the changes instead.</param>
        private void ApplyChanges(ChangeSet cs, bool backward) {
            mReanalysisTimer.Clear();
            mReanalysisTimer.StartTask("ProjectView.ApplyChanges()");

            mReanalysisTimer.StartTask("Save selection");
            mMainWin.CodeListView_DebugValidateSelectionCount();
            int topItemIndex = mMainWin.CodeListView_GetTopIndex();
            LineListGen.SavedSelection savedSel = LineListGen.SavedSelection.Generate(
                CodeLineList, mMainWin.CodeDisplayList.SelectedIndices, topItemIndex);
            //savedSel.DebugDump();

            // Clear this so we don't try to fiddle with it later.
            mTargetHighlightIndex = -1;
            mReanalysisTimer.EndTask("Save selection");

            mReanalysisTimer.StartTask("Apply changes");
            UndoableChange.ReanalysisScope needReanalysis = mProject.ApplyChanges(cs, backward,
                out RangeSet affectedOffsets);
            mReanalysisTimer.EndTask("Apply changes");

            string refreshTaskStr = "Refresh w/reanalysis=" + needReanalysis;
            mReanalysisTimer.StartTask(refreshTaskStr);
            if (needReanalysis != UndoableChange.ReanalysisScope.None) {
                Debug.WriteLine("Refreshing project (" + needReanalysis + ")");
                RefreshProject(needReanalysis);
            } else {
                Debug.WriteLine("Refreshing " + affectedOffsets.Count + " offsets");
                RefreshCodeListViewEntries(affectedOffsets);
                mProject.Validate();    // shouldn't matter w/o reanalysis, but do it anyway
            }
            mReanalysisTimer.EndTask(refreshTaskStr);

            DisplayListSelection newSel = savedSel.Restore(CodeLineList, out topItemIndex);
            //newSel.DebugDump();

            // Restore the selection.  The selection-changed event will cause updates to the
            // references, notes, and info panels.
            mReanalysisTimer.StartTask("Restore selection and top position");
            mMainWin.CodeListView_SetSelection(newSel);
            mMainWin.CodeListView_SetTopIndex(topItemIndex);
            mReanalysisTimer.EndTask("Restore selection and top position");

            // Update the Notes and Symbols windows.  References should refresh automatically
            // when the selection is restored.
            PopulateNotesList();
            PopulateSymbolsList();

            mReanalysisTimer.EndTask("ProjectView.ApplyChanges()");

            //mReanalysisTimer.DumpTimes("ProjectView timers:", mGenerationLog);
            if (mShowAnalysisTimersDialog != null) {
                string timerStr = mReanalysisTimer.DumpToString("ProjectView timers:");
                mShowAnalysisTimersDialog.DisplayText = timerStr;
            }

            // Lines may have moved around.  Update the selection highlight.  It's important
            // we do it here, and not down in DoRefreshProject(), because at that point the
            // ListView's selection index could be referencing a line off the end.
            // (This may not be necessary with WPF, because the way highlights work changed.)
            UpdateSelectionHighlight();
        }

        /// <summary>
        /// Updates all of the specified ListView entries.  This is called after minor changes,
        /// such as editing a comment or renaming a label, that can be handled by regenerating
        /// selected parts of the DisplayList.
        /// </summary>
        /// <param name="offsetSet"></param>
        private void RefreshCodeListViewEntries(RangeSet offsetSet) {
            IEnumerator<RangeSet.Range> iter = offsetSet.RangeListIterator;
            while (iter.MoveNext()) {
                RangeSet.Range range = iter.Current;
                CodeLineList.GenerateRange(range.Low, range.High);
            }
        }

        /// <summary>
        /// Refreshes the project after something of substance has changed.  Some
        /// re-analysis will be done, followed by a complete rebuild of the DisplayList.
        /// </summary>
        /// <param name="reanalysisRequired">Indicates whether reanalysis is required, and
        ///   what level.</param>
        private void RefreshProject(UndoableChange.ReanalysisScope reanalysisRequired) {
            Debug.Assert(reanalysisRequired != UndoableChange.ReanalysisScope.None);

            // NOTE: my goal is to arrange things so that reanalysis (data-only, and ideally
            // code+data) takes less than 100ms.  With that response time there's no need for
            // background processing and progress bars.  Since we need to do data-only
            // reanalysis after many common operations, the program becomes unpleasant to
            // use if we miss this goal, and progress bars won't make it less so.

            if (mProject.FileDataLength > 65536) {
                try {
                    Mouse.OverrideCursor = Cursors.Wait;
                    DoRefreshProject(reanalysisRequired);
                } finally {
                    Mouse.OverrideCursor = null;
                }
            } else {
                DoRefreshProject(reanalysisRequired);
            }

            if (mGenerationLog != null) {
                //mReanalysisTimer.StartTask("Save _log");
                //mGenerationLog.WriteToFile(@"C:\Src\WorkBench\SourceGen\TestData\_log.txt");
                //mReanalysisTimer.EndTask("Save _log");

                if (mShowAnalyzerOutputDialog != null) {
                    mShowAnalyzerOutputDialog.DisplayText = mGenerationLog.WriteToString();
                }
            }

            if (FormatDescriptor.DebugCreateCount != 0) {
                Debug.WriteLine("FormatDescriptor total=" + FormatDescriptor.DebugCreateCount +
                    " prefab=" + FormatDescriptor.DebugPrefabCount + " (" +
                    (FormatDescriptor.DebugPrefabCount * 100) / FormatDescriptor.DebugCreateCount +
                    "%)");
            }
        }

        /// <summary>
        /// Refreshes the project after something of substance has changed.
        /// </summary>
        /// <remarks>
        /// Ideally from this point on we can run on a background thread.  The tricky part
        /// is the close relationship between LineListGen and DisplayList -- we can't update
        /// DisplayList from a background thread.  Until that's fixed, putting up a "working..."
        /// dialog or other UI will be awkward.
        /// </remarks>
        /// <param name="reanalysisRequired">Indicates whether reanalysis is required, and
        ///   what level.</param>
        private void DoRefreshProject(UndoableChange.ReanalysisScope reanalysisRequired) {
            // Changing the CPU type or whether undocumented instructions are supported
            // invalidates the Formatter's mnemonic cache.  We can change these values
            // through undo/redo, so we need to check it here.
            if (mFormatterCpuDef != mProject.CpuDef) {    // reference equality is fine
                Debug.WriteLine("CpuDef has changed, resetting formatter (now " +
                    mProject.CpuDef + ")");
                mFormatter = new Formatter(mFormatterConfig);
                CodeLineList.SetFormatter(mFormatter);
                CodeLineList.SetPseudoOpNames(mPseudoOpNames);
                mFormatterCpuDef = mProject.CpuDef;
            }

            if (reanalysisRequired != UndoableChange.ReanalysisScope.DisplayOnly) {
                mGenerationLog = new CommonUtil.DebugLog();
                mGenerationLog.SetMinPriority(CommonUtil.DebugLog.Priority.Debug);
                mGenerationLog.SetShowRelTime(true);

                mReanalysisTimer.StartTask("Call DisasmProject.Analyze()");
                mProject.Analyze(reanalysisRequired, mGenerationLog, mReanalysisTimer);
                mReanalysisTimer.EndTask("Call DisasmProject.Analyze()");

                mReanalysisTimer.StartTask("Update message list");
                mMainWin.UpdateMessageList(mProject.Messages, mFormatter);
                mReanalysisTimer.EndTask("Update message list");
            }

            mReanalysisTimer.StartTask("Generate DisplayList");
            CodeLineList.GenerateAll();
            mReanalysisTimer.EndTask("Generate DisplayList");

            mReanalysisTimer.StartTask("Refresh Visualization thumbnails");
            VisualizationSet.RefreshAllThumbnails(mProject);
            mReanalysisTimer.EndTask("Refresh Visualization thumbnails");
        }

        #endregion Project management

        #region Main window UI event handlers

        public void NewProject() {
            if (!CloseProject()) {
                return;
            }

            string sysDefsPath = RuntimeDataAccess.GetPathName("SystemDefs.json");
            if (sysDefsPath == null) {
                MessageBox.Show(Res.Strings.ERR_LOAD_CONFIG_FILE, Res.Strings.OPERATION_FAILED,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }

            SystemDefSet sds = null;
            try {
                sds = SystemDefSet.ReadFile(sysDefsPath);
            } catch (Exception ex) {
                Debug.WriteLine("Failed loading system def set: " + ex);
                MessageBox.Show(Res.Strings.ERR_LOAD_CONFIG_FILE, Res.Strings.OPERATION_FAILED,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }

            NewProject dlg = new NewProject(mMainWin, sds);
            if (dlg.ShowDialog() != true) {
                return;
            }
            bool ok = PrepareNewProject(Path.GetFullPath(dlg.DataFileName), dlg.SystemDef);
            if (ok) {
                FinishPrep();
            }
        }

        public void OpenRecentProject(int projIndex) {
            if (!CloseProject()) {
                return;
            }
            DoOpenFile(RecentProjectPaths[projIndex]);
        }

        /// <summary>
        /// Handles opening an existing project by letting the user select the project file.
        /// </summary>
        public void OpenProject() {
            if (!CloseProject()) {
                return;
            }

            OpenFileDialog fileDlg = new OpenFileDialog() {
                Filter = ProjectFile.FILENAME_FILTER + "|" + Res.Strings.FILE_FILTER_ALL,
                FilterIndex = 1
            };
            if (fileDlg.ShowDialog() != true) {
                return;
            }

            string projPathName = Path.GetFullPath(fileDlg.FileName);
            DoOpenFile(projPathName);
        }

        /// <summary>
        /// Handles opening an existing project, given a full pathname to the project file.
        /// </summary>
        private void DoOpenFile(string projPathName) {
            Debug.WriteLine("DoOpenFile: " + projPathName);
            Debug.Assert(mProject == null);

            if (!File.Exists(projPathName)) {
                // Should only happen for projects in "recents".
                string msg = string.Format(Res.Strings.ERR_FILE_NOT_FOUND_FMT, projPathName);
                MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }

            DisasmProject newProject = new DisasmProject();
            newProject.UseMainAppDomainForPlugins = mUseMainAppDomainForPlugins;

            // Deserialize the project file.  I want to do this before loading the data file
            // in case we decide to store the data file name in the project (e.g. the data
            // file is a disk image or zip archive, and we need to know which part(s) to
            // extract).
            if (!ProjectFile.DeserializeFromFile(projPathName, newProject,
                    out FileLoadReport report)) {
                // Should probably use a less-busy dialog for something simple like
                // "permission denied", but the open file dialog handles most simple
                // stuff directly.
                ProjectLoadIssues dlg = new ProjectLoadIssues(mMainWin, report.Format(),
                    ProjectLoadIssues.Buttons.Cancel);
                dlg.ShowDialog();
                // ignore dlg.DialogResult
                return;
            }

            // Now open the data file, generating the pathname by stripping off the ".dis65"
            // extension.  If we can't find the file, show a message box and offer the option to
            // locate it manually, repeating the process until successful or canceled.
            const string UNKNOWN_FILE = "UNKNOWN";
            string dataPathName;
            if (projPathName.Length <= ProjectFile.FILENAME_EXT.Length) {
                dataPathName = UNKNOWN_FILE;
            } else {
                dataPathName = projPathName.Substring(0,
                    projPathName.Length - ProjectFile.FILENAME_EXT.Length);
            }
            byte[] fileData;
            while ((fileData = FindValidDataFile(ref dataPathName, newProject,
                    out bool cancel)) == null) {
                if (cancel) {
                    // give up
                    Debug.WriteLine("Abandoning attempt to open project");
                    return;
                }
            }

            newProject.SetFileData(fileData, Path.GetFileName(dataPathName), ref report);

            // If there were warnings, notify the user and give the a chance to cancel.
            if (report.Count != 0) {
                ProjectLoadIssues dlg = new ProjectLoadIssues(mMainWin, report.Format(),
                    ProjectLoadIssues.Buttons.ContinueOrCancel);
                bool? ok = dlg.ShowDialog();

                if (ok != true) {
                    return;
                }

                newProject.IsReadOnly = dlg.WantReadOnly;
            }

            mProject = newProject;
            mProjectPathName = mProject.ProjectPathName = projPathName;
            mDataPathName = dataPathName;
            FinishPrep();
        }

        /// <summary>
        /// Finds and loads the specified data file.  The file's length and CRC must match
        /// the project's expectations.
        /// </summary>
        /// <param name="dataPathName">Full path to file.</param>
        /// <param name="proj">Project object.</param>
        /// <param name="cancel">Returns true if we want to cancel the attempt.</param>
        /// <returns></returns>
        private byte[] FindValidDataFile(ref string dataPathName, DisasmProject proj,
                out bool cancel) {
            FileInfo fi = new FileInfo(dataPathName);
            if (!fi.Exists) {
                Debug.WriteLine("File '" + dataPathName + "' doesn't exist");
                dataPathName = ChooseDataFile(dataPathName,
                    Res.Strings.OPEN_DATA_DOESNT_EXIST);
                cancel = (dataPathName == null);
                return null;
            }
            if (fi.Length != proj.FileDataLength) {
                Debug.WriteLine("File '" + dataPathName + "' has length=" + fi.Length +
                    ", expected " + proj.FileDataLength);
                dataPathName = ChooseDataFile(dataPathName,
                    string.Format(Res.Strings.OPEN_DATA_WRONG_LENGTH_FMT,
                        fi.Length, proj.FileDataLength));
                cancel = (dataPathName == null);
                return null;
            }
            byte[] fileData = null;
            try {
                fileData = LoadDataFile(dataPathName);
            } catch (Exception ex) {
                Debug.WriteLine("File '" + dataPathName + "' failed to load: " + ex.Message);
                dataPathName = ChooseDataFile(dataPathName,
                    string.Format(Res.Strings.OPEN_DATA_LOAD_FAILED_FMT, ex.Message));
                cancel = (dataPathName == null);
                return null;
            }
            uint crc = CRC32.OnWholeBuffer(0, fileData);
            if (crc != proj.FileDataCrc32) {
                Debug.WriteLine("File '" + dataPathName + "' has CRC32=" + crc +
                    ", expected " + proj.FileDataCrc32);
                // Format the CRC as signed decimal, so that interested parties can
                // easily replace the value in the .dis65 file.
                dataPathName = ChooseDataFile(dataPathName,
                    string.Format(Res.Strings.OPEN_DATA_WRONG_CRC_FMT,
                        (int)crc, (int)proj.FileDataCrc32));
                cancel = (dataPathName == null);
                return null;
            }

            cancel = false;
            return fileData;
        }

        /// <summary>
        /// Displays a "do you want to pick a different file" message, then (on OK) allows the
        /// user to select a file.
        /// </summary>
        /// <param name="origPath">Pathname of original file.</param>
        /// <param name="errorMsg">Message to display in the message box.</param>
        /// <returns>Full path of file to open.</returns>
        private string ChooseDataFile(string origPath, string errorMsg) {
            DataFileLoadIssue dlg = new DataFileLoadIssue(mMainWin, origPath, errorMsg);
            bool? ok = dlg.ShowDialog();
            if (ok != true) {
                return null;
            }

            OpenFileDialog fileDlg = new OpenFileDialog() {
                FileName = Path.GetFileName(origPath),
                Filter = Res.Strings.FILE_FILTER_ALL
            };
            if (fileDlg.ShowDialog() != true) {
                return null;
            }

            string newPath = Path.GetFullPath(fileDlg.FileName);
            Debug.WriteLine("User selected data file " + newPath);
            return newPath;
        }

        /// <summary>
        /// Saves the project, querying for the filename.
        /// </summary>
        /// <returns>True on success, false if the save attempt failed or was canceled.</returns>
        public bool SaveProjectAs() {
            Debug.Assert(!mProject.IsReadOnly);
            SaveFileDialog fileDlg = new SaveFileDialog() {
                Filter = ProjectFile.FILENAME_FILTER + "|" + Res.Strings.FILE_FILTER_ALL,
                FilterIndex = 1,
                ValidateNames = true,
                AddExtension = true,
                FileName = Path.GetFileName(mDataPathName) + ProjectFile.FILENAME_EXT
            };
            if (fileDlg.ShowDialog() != true) {
                Debug.WriteLine("SaveAs canceled by user");
                return false;
            }
            string pathName = Path.GetFullPath(fileDlg.FileName);
            Debug.WriteLine("Project save path: " + pathName);
            if (!DoSave(pathName)) {
                return false;
            }

            // Success, record the path name.
            mProjectPathName = mProject.ProjectPathName = pathName;

            // add it to the title bar
            UpdateTitle();
            return true;
        }

        /// <summary>
        /// Saves the project.  If it hasn't been saved before, use save-as behavior instead.
        /// </summary>
        /// <returns>True on success, false if the save attempt failed.</returns>
        public bool SaveProject() {
            Debug.Assert(!mProject.IsReadOnly);
            if (string.IsNullOrEmpty(mProjectPathName)) {
                return SaveProjectAs();
            }
            return DoSave(mProjectPathName);
        }

        private bool DoSave(string pathName) {
            Debug.WriteLine("SAVING " + pathName);
            if (!ProjectFile.SerializeToFile(mProject, pathName, out string errorMessage)) {
                MessageBox.Show(Res.Strings.ERR_PROJECT_SAVE_FAIL + ": " + errorMessage,
                    Res.Strings.OPERATION_FAILED,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                return false;
            }

            mProject.ResetDirtyFlag();
            // If the debug dialog is visible, update it.
            if (mShowUndoRedoHistoryDialog != null) {
                mShowUndoRedoHistoryDialog.DisplayText = mProject.DebugGetUndoRedoHistory();
            }
            UpdateTitle();

            // Update this, in case this was a new project.
            UpdateRecentProjectList(pathName);

            // Seems like a good time to save this off too.
            SaveAppSettings();

            return true;
        }

        /// <summary>
        /// Handles main window closing.
        /// </summary>
        /// <returns>True if it's okay for the window to close, false to cancel it.</returns>
        public bool WindowClosing() {
            SaveAppSettings();
            if (!CloseProject()) {
                return false;
            }

            // WPF won't exit until all windows are closed, so any unowned windows need
            // to be cleaned up here.
            mAsciiChartDialog?.Close();
            mInstructionChartDialog?.Close();
            mHexDumpDialog?.Close();
            mShowAnalysisTimersDialog?.Close();
            mShowAnalyzerOutputDialog?.Close();
            mShowUndoRedoHistoryDialog?.Close();

            while (mUnownedWindows.Count > 0) {
                int count = mUnownedWindows.Count;
                mUnownedWindows[0].Close();
                if (count == mUnownedWindows.Count) {
                    // Window failed to remove itself; this will cause an infinite loop.
                    // The user will have to close them manually.
                    Debug.Assert(false, "Failed to close window " + mUnownedWindows[0]);
                    break;
                }
            }

            return true;
        }

        /// <summary>
        /// Closes the project and associated modeless dialogs.  Unsaved changes will be
        /// lost, so if the project has outstanding changes the user will be given the
        /// opportunity to cancel.
        /// </summary>
        /// <returns>True if the project was closed, false if the user chose to cancel.</returns>
        public bool CloseProject() {
            Debug.WriteLine("CloseProject() - dirty=" +
                (mProject == null ? "N/A" : mProject.IsDirty.ToString()));
            if (mProject != null && mProject.IsDirty) {
                DiscardChanges dlg = new DiscardChanges(mMainWin);
                bool? ok = dlg.ShowDialog();
                if (ok != true) {
                    return false;
                } else if (dlg.UserChoice == DiscardChanges.Choice.SaveAndContinue) {
                    if (!SaveProject()) {
                        return false;
                    }
                }
            }

            // Close modeless dialogs that depend on project.
            mHexDumpDialog?.Close();
            mShowAnalysisTimersDialog?.Close();
            mShowAnalyzerOutputDialog?.Close();
            mShowUndoRedoHistoryDialog?.Close();

            // Discard all project state.
            if (mProject != null) {
                mProject.Cleanup();
                mProject = null;
            }
            mDataPathName = null;
            mProjectPathName = null;
            mTargetHighlightIndex = -1;

            mMainWin.ShowCodeListView = false;
            mMainWin.ProjectClosing();

            mGenerationLog = null;

            UpdateTitle();

            // Not necessary, but it lets us check the memory monitor to see if we got
            // rid of everything.
            GC.Collect();

            return true;
        }

        public bool IsProjectOpen {
            get { return mProject != null; }
        }
        public bool IsProjectReadOnly {
            get { return mProject != null && mProject.IsReadOnly; }
        }

        public void AssembleProject() {
            if (string.IsNullOrEmpty(mProjectPathName)) {
                // We need a project pathname so we know where to write the assembler
                // source files, and what to call the output files.  We could just pop up the
                // Save As dialog, but that seems confusing unless we do a custom dialog with
                // an explanation, or have some annoying click-through.
                //
                // This only appears for never-saved projects, not projects with unsaved data.
                MessageBox.Show(Res.Strings.SAVE_BEFORE_ASM, Res.Strings.SAVE_BEFORE_ASM_CAPTION,
                    MessageBoxButton.OK, MessageBoxImage.Information);
                return;
            }

            AsmGen.WpfGui.GenAndAsm dlg =
                new AsmGen.WpfGui.GenAndAsm(mMainWin, this, mProject, mProjectPathName);
            dlg.ShowDialog();
        }

        /// <summary>
        /// Copies the selection to the clipboard as formatted text.
        /// </summary>
        public void CopyToClipboard() {
            DisplayListSelection selection = mMainWin.CodeDisplayList.SelectedIndices;
            if (selection.Count == 0) {
                Debug.WriteLine("Selection is empty!");
                return;
            }

            ClipLineFormat format = (ClipLineFormat)AppSettings.Global.GetEnum(
                    AppSettings.CLIP_LINE_FORMAT,
                    typeof(ClipLineFormat),
                    (int)ClipLineFormat.AssemblerSource);

            int[] rightWidths = new int[] { 9, 8, 11, 100 };

            Exporter.ActiveColumnFlags colFlags = Exporter.ActiveColumnFlags.None;
            if (format == ClipLineFormat.Disassembly) {
                colFlags |= Exporter.ActiveColumnFlags.Address |
                    Exporter.ActiveColumnFlags.Bytes;
            } else if (format == ClipLineFormat.AllColumns) {
                colFlags = Exporter.ActiveColumnFlags.ALL;
            }
            Exporter eport = new Exporter(mProject, CodeLineList, mFormatter,
                colFlags, rightWidths);
            eport.Selection = selection;

            // Might want to set Mouse.OverrideCursor if the selection exceeds a few
            // hundred thousand lines.
            eport.SelectionToString(true, out string fullText, out string csvText);

            DataObject dataObject = new DataObject();
            dataObject.SetText(fullText.ToString());

            // We want to have both plain text and CSV data on the clipboard.  To add both
            // formats we need to stream it to a DataObject.  Complicating matters is Excel's
            // entirely reasonable desire to have data in UTF-8 rather than UTF-16.
            //
            // (I'm not sure pasting assembly bits into Excel is actually useful, so this
            // should probably be optional.)
            //
            // https://stackoverflow.com/a/369219/294248
            const bool addCsv = true;
            if (addCsv) {
                byte[] csvData = Encoding.UTF8.GetBytes(csvText.ToString());
                MemoryStream stream = new MemoryStream(csvData);
                dataObject.SetData(DataFormats.CommaSeparatedValue, stream);
            }
            Clipboard.SetDataObject(dataObject, true);
        }

        /// <summary>
        /// Opens the application settings dialog.  All changes to settings are made directly
        /// to the AppSettings.Global object.
        /// </summary>
        public void EditAppSettings() {
            ShowAppSettings(mMainWin, WpfGui.EditAppSettings.Tab.Unknown,
                AsmGen.AssemblerInfo.Id.Unknown);
        }

        public void ShowAppSettings(Window owner, EditAppSettings.Tab initialTab,
                    AsmGen.AssemblerInfo.Id initialAsmId) {
            EditAppSettings dlg = new EditAppSettings(owner, mMainWin, this,
                initialTab, initialAsmId);
            dlg.ShowDialog();

            // The settings code calls SetAppSettings() directly whenever "Apply" is hit.
        }

        public void HandleCodeListDoubleClick(int row, int col) {
            //Debug.WriteLine("DCLICK: row=" + row + " col=" + col);
            mMainWin.CodeListView_DebugValidateSelectionCount();

            // Clicking on some types of lines, such as ORG directives, results in
            // specific behavior regardless of which column you click in.  We're just
            // checking the clicked-on line to decide what action to take.  If it doesn't
            // make sense to do for a multi-line selection, the action will have been
            // disabled.
            LineListGen.Line line = CodeLineList[row];
            switch (line.LineType) {
                case LineListGen.Line.Type.EquDirective:
                    // Currently only does something for project symbols; platform symbols
                    // do nothing.
                    if (CanEditProjectSymbol()) {
                        EditProjectSymbol((CodeListColumn)col);
                    }
                    break;
                case LineListGen.Line.Type.OrgDirective:
                    if (CanEditAddress()) {
                        EditAddress();
                    }
                    break;
                case LineListGen.Line.Type.RegWidthDirective:
                    if (CanEditStatusFlags()) {
                        EditStatusFlags();
                    }
                    break;
                case LineListGen.Line.Type.LongComment:
                    if (CanEditLongComment()) {
                        EditLongComment();
                    }
                    break;
                case LineListGen.Line.Type.Note:
                    if (CanEditNote()) {
                        EditNote();
                    }
                    break;
                case LineListGen.Line.Type.LocalVariableTable:
                    if (CanEditLocalVariableTable()) {
                        EditLocalVariableTable();
                    }
                    break;
                case LineListGen.Line.Type.VisualizationSet:
                    if (CanEditVisualizationSet()) {
                        EditVisualizationSet();
                    }
                    break;

                case LineListGen.Line.Type.Code:
                case LineListGen.Line.Type.Data:
                    // For code and data, we have to break it down by column.
                    switch ((CodeListColumn)col) {
                        case CodeListColumn.Offset:
                            // does nothing
                            break;
                        case CodeListColumn.Address:
                            // edit address
                            if (CanEditAddress()) {
                                EditAddress();
                            }
                            break;
                        case CodeListColumn.Bytes:
                            ShowHexDump();
                            break;
                        case CodeListColumn.Flags:
                            if (CanEditStatusFlags()) {
                                EditStatusFlags();
                            }
                            break;
                        case CodeListColumn.Attributes:
                            // does nothing
                            break;
                        case CodeListColumn.Label:
                            if (CanEditLabel()) {
                                EditLabel();
                            }
                            break;
                        case CodeListColumn.Opcode:
                            JumpToOperandTarget(line, false);
                            break;
                        case CodeListColumn.Operand:
                            if (CanEditOperand()) {
                                EditOperand();
                            }
                            break;
                        case CodeListColumn.Comment:
                            if (CanEditComment()) {
                                EditComment();
                            }
                            break;

                    }
                    break;

                default:
                    Debug.WriteLine("Double-click: unhandled line type " + line.LineType);
                    break;
            }
        }

        /// <summary>
        /// Jumps to the line referenced by the operand of the selected line.
        /// </summary>
        /// <param name="line">Selected line.</param>
        /// <param name="testOnly">If set, don't actually do the goto.</param>
        /// <returns>True if a jump is available for this line.</returns>
        private bool JumpToOperandTarget(LineListGen.Line line, bool testOnly) {
            if (line.FileOffset < 0) {
                // Double-click on project symbol EQUs and the file header comment are handled
                // elsewhere.
                return false;
            }

            Anattrib attr = mProject.GetAnattrib(line.FileOffset);
            FormatDescriptor dfd = attr.DataDescriptor;

            // Does this have an operand with an in-file target offset?
            // (Resolve it as a numeric reference.)
            if (attr.OperandOffset >= 0) {
                // Yup, find the line for that offset and jump to it.
                if (!testOnly) {
                    GoToLocation(new NavStack.Location(attr.OperandOffset, 0, false),
                        GoToMode.JumpToCodeData, true);
                }
                return true;
            } else if (dfd != null && dfd.HasSymbol) {
                // Operand has a symbol, do a symbol lookup.
                if (dfd.SymbolRef.IsVariable) {
                    if (!testOnly) {
                        GoToVarDefinition(line.FileOffset, dfd.SymbolRef, true);
                    }
                    return true;
                } else {
                    if (mProject.SymbolTable.TryGetValue(dfd.SymbolRef.Label, out Symbol sym)) {
                        if (sym.SymbolSource == Symbol.Source.User ||
                                sym.SymbolSource == Symbol.Source.Auto) {
                            int labelOffset = mProject.FindLabelOffsetByName(dfd.SymbolRef.Label);
                            if (labelOffset >= 0) {
                                if (!testOnly) {
                                    GoToLocation(new NavStack.Location(labelOffset, 0, false),
                                        GoToMode.JumpToCodeData, true);
                                }
                                return true;
                            }
                        } else if (sym.SymbolSource == Symbol.Source.Platform ||
                                sym.SymbolSource == Symbol.Source.Project) {
                            // find entry
                            for (int i = 0; i < mProject.ActiveDefSymbolList.Count; i++) {
                                if (mProject.ActiveDefSymbolList[i] == sym) {
                                    int offset = LineListGen.DefSymOffsetFromIndex(i);
                                    if (!testOnly) {
                                        GoToLocation(new NavStack.Location(offset, 0, false),
                                            GoToMode.JumpToCodeData, true);
                                    }
                                    return true;
                                }
                            }
                        } else {
                            Debug.Assert(false);
                        }
                    } else {
                        // must be a broken weak symbol ref
                        Debug.WriteLine("Operand symbol not found: " + dfd.SymbolRef.Label);
                    }
                }
            } else if (attr.IsDataStart || attr.IsInlineDataStart) {
                // If it's an Address or Symbol, we can try to resolve
                // the value.  (Symbols should have been resolved by the
                // previous clause, but Address entries would not have been.)
                int operandOffset = DataAnalysis.GetDataOperandOffset(mProject, line.FileOffset);
                if (operandOffset >= 0) {
                    if (!testOnly) {
                        GoToLocation(new NavStack.Location(operandOffset, 0, false),
                            GoToMode.JumpToCodeData, true);
                    }
                    return true;
                }
            }

            return false;
        }

        public bool CanDeleteMlc() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            return (SelectionAnalysis.mLineType == LineListGen.Line.Type.LongComment ||
                SelectionAnalysis.mLineType == LineListGen.Line.Type.Note);
        }

        // Delete multi-line comment (Note or LongComment)
        public void DeleteMlc() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            LineListGen.Line line = CodeLineList[selIndex];
            int offset = line.FileOffset;

            UndoableChange uc;
            if (line.LineType == LineListGen.Line.Type.Note) {
                if (!mProject.Notes.TryGetValue(offset, out MultiLineComment oldNote)) {
                    Debug.Assert(false);
                    return;
                }
                uc = UndoableChange.CreateNoteChange(offset, oldNote, null);
            } else if (line.LineType == LineListGen.Line.Type.LongComment) {
                if (!mProject.LongComments.TryGetValue(offset, out MultiLineComment oldComment)) {
                    Debug.Assert(false);
                    return;
                }
                uc = UndoableChange.CreateLongCommentChange(offset, oldComment, null);
            } else {
                Debug.Assert(false);
                return;
            }
            ChangeSet cs = new ChangeSet(uc);
            ApplyUndoableChanges(cs);
        }

        public bool CanEditAddress() {
            // First line must be code, data, or an ORG directive.
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (selIndex < 0) {
                return false;
            }
            LineListGen.Line selLine = CodeLineList[selIndex];
            if (selLine.LineType != LineListGen.Line.Type.Code &&
                    selLine.LineType != LineListGen.Line.Type.Data &&
                    selLine.LineType != LineListGen.Line.Type.OrgDirective) {
                return false;
            }

            // If multiple lines are selected, there must not be an address change between them.
            int lastIndex = mMainWin.CodeListView_GetLastSelectedIndex();
            int firstOffset = CodeLineList[selIndex].FileOffset;
            int lastOffset = CodeLineList[lastIndex].FileOffset;
            if (firstOffset == lastOffset) {
                // Single-item selection, we're fine.
                return true;
            }

            int nextOffset = lastOffset + CodeLineList[lastIndex].OffsetSpan;

            foreach (AddressMap.AddressMapEntry ent in mProject.AddrMap) {
                // It's okay to have an existing entry at firstOffset or nextOffset.
                if (ent.Offset > firstOffset && ent.Offset < nextOffset) {
                    Debug.WriteLine("Found mid-selection AddressMap entry at +" +
                        ent.Offset.ToString("x6"));
                    return false;
                }
            }

            return true;
        }

        public void EditAddress() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int lastIndex = mMainWin.CodeListView_GetLastSelectedIndex();
            int firstOffset = CodeLineList[selIndex].FileOffset;
            int lastOffset = CodeLineList[lastIndex].FileOffset;
            int nextOffset = lastOffset + CodeLineList[lastIndex].OffsetSpan;
            int nextAddr;

            if (firstOffset == lastOffset || nextOffset == mProject.FileDataLength) {
                // Single item (which may not be a single *line*) is selected, or the
                // last selected item is the end of the file.
                nextOffset = -1;
                nextAddr = AddressMap.NO_ENTRY_ADDR;
            } else {
                // Compute "nextAddr".  If there's an existing entry at nextOffset, we use
                // that.  If not, we use the "load address", which is determined by the very
                // first address.
                //
                // I tried this by just removing the selected entry and seeing what the address
                // would be without it, useful for relocations inside relocations.  This worked
                // poorly when relocations were chained, i.e. two consecutive blocks were
                // relocated to different places.  The end address of the second block gets
                // set based on the first address of the first block, which doesn't seem useful.
#if false
                nextAddr = mProject.AddrMap.Get(nextOffset);
                if (nextAddr == AddressMap.NO_ENTRY_ADDR) {
                    AddressMap cloneMap = new AddressMap(mProject.AddrMap.GetEntryList());
                    if (firstOffset != 0) {
                        cloneMap.Remove(firstOffset);
                    }
                    nextAddr = cloneMap.OffsetToAddress(nextOffset);
                }
#else
                int fileStartAddr = mProject.AddrMap.OffsetToAddress(0);
                nextAddr = ((fileStartAddr + nextOffset) & 0xffff) | (fileStartAddr & 0xff0000);
#endif
            }

            EditAddress dlg = new EditAddress(mMainWin, firstOffset, nextOffset, nextAddr,
                mProject, mFormatter);
            if (dlg.ShowDialog() != true) {
                return;
            }

            if (firstOffset == 0 && dlg.NewAddress < 0) {
                // Not allowed.  The AddressMap will just put it back, which confuses
                // the undo operation.
                Debug.WriteLine("EditAddress: not allowed to remove address at offset +000000");
                return;
            }

            ChangeSet cs = new ChangeSet(1);

            if (mProject.AddrMap.Get(firstOffset) != dlg.NewAddress) {
                // Added / removed / changed existing entry.
                //
                // We allow creation of an apparently redundant address override, because
                // sometimes it's helpful to add one to "anchor" an area before relocating
                // something that appears earlier in the file.
                int prevAddress = mProject.AddrMap.Get(firstOffset);
                UndoableChange uc = UndoableChange.CreateAddressChange(firstOffset,
                    prevAddress, dlg.NewAddress);
                cs.Add(uc);
                Debug.WriteLine("EditAddress: changing addr at offset +" +
                    firstOffset.ToString("x6") + " to $" + dlg.NewAddress.ToString("x4"));
            }

            // We want to create an entry for the chunk that follows the selected area.
            // We don't modify the trailing address if an entry already exists.
            // (Note the "can edit" code prevented us from being called if there's an
            // address map entry in the middle of the selected area.)
            //
            // If they're removing an existing entry, don't add a new entry at the end.
            if (nextAddr >= 0 && dlg.NewAddress != AddressMap.NO_ENTRY_ADDR &&
                    mProject.AddrMap.Get(nextOffset) == AddressMap.NO_ENTRY_ADDR) {
                // We don't screen for redundant entries here.  That should only happen if
                // they select a range and then don't change the address.  Maybe it's useful?
                int prevAddress = mProject.AddrMap.Get(nextOffset);
                UndoableChange uc = UndoableChange.CreateAddressChange(nextOffset,
                    prevAddress, nextAddr);
                cs.Add(uc);
                Debug.WriteLine("EditAddress: setting trailing addr at offset +" +
                    nextOffset.ToString("x6") + " to $" + nextAddr.ToString("x4"));
            }

            if (cs.Count > 0) {
                ApplyUndoableChanges(cs);
            } else {
                Debug.WriteLine("EditAddress: no changes");
            }
        }

        public bool CanEditComment() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            // Line must be code or data.
            return (SelectionAnalysis.mLineType == LineListGen.Line.Type.Code ||
                SelectionAnalysis.mLineType == LineListGen.Line.Type.Data);
        }

        public void EditComment() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;

            string oldComment = mProject.Comments[offset];
            EditComment dlg = new EditComment(mMainWin, oldComment);
            if (dlg.ShowDialog() == true) {
                if (!oldComment.Equals(dlg.CommentText)) {
                    Debug.WriteLine("Changing comment at +" + offset.ToString("x6"));

                    UndoableChange uc = UndoableChange.CreateCommentChange(offset,
                        oldComment, dlg.CommentText);
                    ChangeSet cs = new ChangeSet(uc);
                    ApplyUndoableChanges(cs);
                }
            }
        }

        public void EditHeaderComment() {
            EditLongComment(LineListGen.Line.HEADER_COMMENT_OFFSET);
        }

        public bool CanEditLabel() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Single line, code or data.
            return (counts.mDataLines > 0 || counts.mCodeLines > 0);
        }

        public void EditLabel() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;

            Anattrib attr = mProject.GetAnattrib(offset);
            EditLabel dlg = new EditLabel(mMainWin, attr.Symbol, attr.Address, offset,
                mProject.SymbolTable, mFormatter);
            if (dlg.ShowDialog() != true) {
                return;
            }

            // NOTE: if label matching is case-insensitive, we want to allow a situation
            // where a label is being renamed from "FOO" to "Foo".  (We should be able to
            // test for object equality on the Symbol.)
            if (attr.Symbol != dlg.LabelSym) {
                Debug.WriteLine("Changing label at offset +" + offset.ToString("x6"));

                // For undo/redo, we want to update the UserLabels value.  This may
                // be different from the Anattrib symbol, which can have an auto-generated
                // value.
                Symbol oldUserValue = null;
                if (mProject.UserLabels.ContainsKey(offset)) {
                    oldUserValue = mProject.UserLabels[offset];
                }
                if (oldUserValue == dlg.LabelSym) {
                    // Only expected when attr.Symbol is Auto
                    Debug.Assert(attr.Symbol.SymbolSource == Symbol.Source.Auto);
                    Debug.Assert(oldUserValue == null);
                    Debug.WriteLine("Ignoring attempt to delete an auto label");
                } else {
                    UndoableChange uc = UndoableChange.CreateLabelChange(offset,
                        oldUserValue, dlg.LabelSym);
                    ChangeSet cs = new ChangeSet(uc);
                    ApplyUndoableChanges(cs);
                }
            }
        }

        public bool CanCreateLocalVariableTable() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            // Only allow on code lines.  This is somewhat arbitrary; data would work fine.
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (CodeLineList[selIndex].LineType != LineListGen.Line.Type.Code) {
                return false;
            }
            int offset = CodeLineList[selIndex].FileOffset;
            // Don't allow creation if a table already exists.
            return !mProject.LvTables.ContainsKey(offset);
        }

        public void CreateLocalVariableTable() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;
            Debug.Assert(!mProject.LvTables.ContainsKey(offset));
            CreateOrEditLocalVariableTable(offset);
        }

        public bool CanEditLocalVariableTable() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            // Check to see if the offset of the first-defined table is less than or equal to
            // the offset of the selected line.  If so, we know there's a table, though we
            // don't know which one.
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;
            return mProject.LvTables.Count > 0 && mProject.LvTables.Keys[0] <= offset;
        }

        public void EditLocalVariableTable() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;

            LocalVariableLookup lvLookup = new LocalVariableLookup(mProject.LvTables, mProject,
                null, false, false);
            int bestOffset = lvLookup.GetNearestTableOffset(offset);
            Debug.Assert(bestOffset >= 0);
            CreateOrEditLocalVariableTable(bestOffset);
        }

        private void CreateOrEditLocalVariableTable(int offset) {
            // Get existing table, if any.
            mProject.LvTables.TryGetValue(offset, out LocalVariableTable oldLvt);

            EditLocalVariableTable dlg = new EditLocalVariableTable(mMainWin, mProject,
                mFormatter, oldLvt, offset);
            if (dlg.ShowDialog() != true) {
                return;
            }
            if (offset != dlg.NewOffset) {
                // Table moved.  We create two changes, one to delete the current table, one
                // to create a new table.
                Debug.Assert(!mProject.LvTables.TryGetValue(dlg.NewOffset,
                    out LocalVariableTable unused));

                UndoableChange rem = UndoableChange.CreateLocalVariableTableChange(offset,
                    oldLvt, null);
                UndoableChange add = UndoableChange.CreateLocalVariableTableChange(dlg.NewOffset,
                    null, dlg.NewTable);
                ChangeSet cs = new ChangeSet(2);
                cs.Add(rem);
                cs.Add(add);
                ApplyUndoableChanges(cs);
            } else if (oldLvt != dlg.NewTable) {
                // New table, edited in place, or deleted.
                UndoableChange uc = UndoableChange.CreateLocalVariableTableChange(offset,
                    oldLvt, dlg.NewTable);
                ChangeSet cs = new ChangeSet(uc);
                ApplyUndoableChanges(cs);
            } else {
                Debug.WriteLine("LvTable unchanged");
            }
        }

        public bool CanEditLongComment() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Single line, code or data, or a long comment.
            return (counts.mDataLines > 0 || counts.mCodeLines > 0 ||
                SelectionAnalysis.mLineType == LineListGen.Line.Type.LongComment);
        }

        public void EditLongComment() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;
            EditLongComment(offset);
        }

        private void EditLongComment(int offset) {
            EditLongComment dlg = new EditLongComment(mMainWin, mFormatter);
            if (mProject.LongComments.TryGetValue(offset, out MultiLineComment oldComment)) {
                dlg.LongComment = oldComment;
            }
            if (dlg.ShowDialog() != true) {
                return;
            }

            MultiLineComment newComment = dlg.LongComment;
            if (oldComment != newComment) {
                Debug.WriteLine("Changing long comment at +" + offset.ToString("x6"));

                UndoableChange uc = UndoableChange.CreateLongCommentChange(offset,
                    oldComment, newComment);
                ChangeSet cs = new ChangeSet(uc);
                ApplyUndoableChanges(cs);
            }
        }

        public bool CanEditNote() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Single line, code or data, or a note.
            return (counts.mDataLines > 0 || counts.mCodeLines > 0 ||
                SelectionAnalysis.mLineType == LineListGen.Line.Type.Note);
        }

        public void EditNote() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;

            MultiLineComment oldNote;
            if (!mProject.Notes.TryGetValue(offset, out oldNote)) {
                oldNote = null;
            }
            EditNote dlg = new EditNote(mMainWin, oldNote);
            dlg.ShowDialog();

            if (dlg.DialogResult != true) {
                return;
            }

            MultiLineComment newNote = dlg.Note;
            if (oldNote != newNote) {
                Debug.WriteLine("Changing note at +" + offset.ToString("x6"));

                UndoableChange uc = UndoableChange.CreateNoteChange(offset,
                    oldNote, newNote);
                ChangeSet cs = new ChangeSet(uc);
                ApplyUndoableChanges(cs);
            }
        }

        public bool CanEditOperand() {
            if (SelectionAnalysis.mNumItemsSelected == 0) {
                return false;
            } else if (SelectionAnalysis.mNumItemsSelected == 1) {
                int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
                int selOffset = CodeLineList[selIndex].FileOffset;

                bool editInstr = (CodeLineList[selIndex].LineType == LineListGen.Line.Type.Code &&
                    mProject.GetAnattrib(selOffset).IsInstructionWithOperand);
                bool editData = (CodeLineList[selIndex].LineType == LineListGen.Line.Type.Data);
                return editInstr || editData;
            } else {
                // Data operands are one of the few things we can edit in bulk.  It's okay
                // if meta-data like ORGs and Notes are selected, but we don't allow it if
                // any code is selected.
                EntityCounts counts = SelectionAnalysis.mEntityCounts;
                return (counts.mDataLines > 0 && counts.mCodeLines == 0);
            }
        }

        public void EditOperand() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int selOffset = CodeLineList[selIndex].FileOffset;
            if (CodeLineList[selIndex].LineType == LineListGen.Line.Type.Code) {
                EditInstructionOperand(selOffset);
            } else {
                // We allow the selection to include meta-data like .org and Notes.
                //Debug.Assert(CodeLineList[selIndex].LineType == LineListGen.Line.Type.Data);
                EditDataOperand(selOffset);
            }
        }

        private void EditInstructionOperand(int offset) {
            EditInstructionOperand dlg = new EditInstructionOperand(mMainWin, mProject,
                offset, mFormatter);
            if (dlg.ShowDialog() != true) {
                return;
            }

            ChangeSet cs = new ChangeSet(1);
            mProject.OperandFormats.TryGetValue(offset, out FormatDescriptor dfd);
            if (dlg.FormatDescriptorResult != dfd) {
                UndoableChange uc = UndoableChange.CreateOperandFormatChange(offset,
                    dfd, dlg.FormatDescriptorResult);
                cs.Add(uc);
            } else {
                Debug.WriteLine("No change to operand format");
            }

            // Check for changes to a local variable table.  The edit dialog can't delete an
            // entire table, so a null value here means no changes were made.
            if (dlg.LocalVariableResult != null) {
                int tableOffset = dlg.LocalVariableTableOffsetResult;
                LocalVariableTable lvt = mProject.LvTables[tableOffset];
                Debug.Assert(lvt != null);  // cannot create a table either
                UndoableChange uc = UndoableChange.CreateLocalVariableTableChange(tableOffset,
                    lvt, dlg.LocalVariableResult);
                cs.Add(uc);
            } else {
                Debug.WriteLine("No change to LvTable");
            }

            // Check for changes to label at operand target address.  Labels can be created,
            // modified, or deleted.
            if (dlg.SymbolEditOffsetResult >= 0) {
                mProject.UserLabels.TryGetValue(dlg.SymbolEditOffsetResult, out Symbol oldLabel);
                UndoableChange uc = UndoableChange.CreateLabelChange(dlg.SymbolEditOffsetResult,
                    oldLabel, dlg.SymbolEditResult);
                cs.Add(uc);
            } else {
                Debug.WriteLine("No change to label");
            }

            // Check for changes to a project property.  The dialog can create a new entry or
            // modify an existing entry.
            if (dlg.ProjectPropertyResult != null) {
                DefSymbol oldSym = dlg.PrevProjectPropertyResult;
                DefSymbol newSym = dlg.ProjectPropertyResult;
                ProjectProperties newProps = new ProjectProperties(mProject.ProjectProps);
                // Add new entry, or replace existing entry.
                if (oldSym != null) {
                    newProps.ProjectSyms.Remove(oldSym.Label);
                }
                newProps.ProjectSyms.Add(newSym.Label, newSym);
                UndoableChange uc = UndoableChange.CreateProjectPropertiesChange(
                    mProject.ProjectProps, newProps);
                cs.Add(uc);
            } else {
                Debug.WriteLine("No change to project property");
            }

            Debug.WriteLine("EditInstructionOperand: " + cs.Count + " changes");
            if (cs.Count != 0) {
                ApplyUndoableChanges(cs);
            }
        }

        private void EditDataOperand(int offset) {
            Debug.Assert(mMainWin.CodeListView_GetSelectionCount() > 0);

            TypedRangeSet trs = GroupedOffsetSetFromSelected();
            if (trs.Count == 0) {
                Debug.Assert(false, "EditDataOperand found nothing to edit"); // shouldn't happen
                return;
            }

            // If the first offset has a FormatDescriptor, pass that in as a recommendation
            // for the default value in the dialog.  This allows single-item editing to work
            // as expected.  If the format can't be applied to the full selection (which
            // would disable that radio button), the dialog will have to pick something
            // that does work.
            //
            // We could pull this out of Anattribs, which would let the dialog reflect the
            // auto-format that the user was just looking at.  However, I think it's better
            // if the dialog shows what's actually there, i.e. no formatting at all.
            IEnumerator<TypedRangeSet.Tuple> iter =
                (IEnumerator<TypedRangeSet.Tuple>)trs.GetEnumerator();
            iter.MoveNext();
            TypedRangeSet.Tuple firstOffset = iter.Current;
            mProject.OperandFormats.TryGetValue(firstOffset.Value, out FormatDescriptor dfd);

            EditDataOperand dlg =
                new EditDataOperand(mMainWin, mProject, mFormatter, trs, dfd);
            if (dlg.ShowDialog() == true) {
                // Merge the changes into the OperandFormats list.  We need to remove all
                // FormatDescriptors that overlap the selected region.  We don't need to
                // pass the selection set in, because the dlg.Results list spans the exact
                // set of ranges.
                //
                // If nothing actually changed, don't generate an undo record.
                ChangeSet cs = mProject.GenerateFormatMergeSet(dlg.Results);
                if (cs.Count != 0) {
                    ApplyUndoableChanges(cs);
                } else {
                    Debug.WriteLine("No change to data formats");
                }
            }
        }

        public void EditProjectProperties(WpfGui.EditProjectProperties.Tab initialTab) {
            string projectDir = string.Empty;
            if (!string.IsNullOrEmpty(mProjectPathName)) {
                projectDir = Path.GetDirectoryName(mProjectPathName);
            }
            EditProjectProperties dlg = new EditProjectProperties(mMainWin, mProject.ProjectProps,
                projectDir, mFormatter, initialTab);
            dlg.ShowDialog();
            ProjectProperties newProps = dlg.NewProps;

            // The dialog result doesn't matter, because the user might have hit "apply"
            // before hitting "cancel".
            if (newProps != null) {
                UndoableChange uc = UndoableChange.CreateProjectPropertiesChange(
                    mProject.ProjectProps, newProps);
                ApplyUndoableChanges(new ChangeSet(uc));
            }
        }

        public bool CanEditProjectSymbol() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            if (SelectionAnalysis.mLineType != LineListGen.Line.Type.EquDirective) {
                return false;
            }
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int symIndex = LineListGen.DefSymIndexFromOffset(CodeLineList[selIndex].FileOffset);
            DefSymbol defSym = mProject.ActiveDefSymbolList[symIndex];
            return (defSym.SymbolSource == Symbol.Source.Project);
        }

        public void EditProjectSymbol(CodeListColumn col) {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int symIndex = LineListGen.DefSymIndexFromOffset(CodeLineList[selIndex].FileOffset);
            DefSymbol origDefSym = mProject.ActiveDefSymbolList[symIndex];
            Debug.Assert(origDefSym.SymbolSource == Symbol.Source.Project);

            EditDefSymbol dlg = new EditDefSymbol(mMainWin, mFormatter,
                mProject.ProjectProps.ProjectSyms, origDefSym, null);

            switch (col) {
                case CodeListColumn.Operand:
                    dlg.InitialFocusField = EditDefSymbol.InputField.Value;
                    break;
                case CodeListColumn.Comment:
                    dlg.InitialFocusField = EditDefSymbol.InputField.Comment;
                    break;
                case CodeListColumn.Label:
                default:
                    dlg.InitialFocusField = EditDefSymbol.InputField.Label;
                    break;
            }

            if (dlg.ShowDialog() == true) {
                ProjectProperties newProps = new ProjectProperties(mProject.ProjectProps);
                newProps.ProjectSyms.Remove(origDefSym.Label);
                newProps.ProjectSyms[dlg.NewSym.Label] = dlg.NewSym;

                UndoableChange uc = UndoableChange.CreateProjectPropertiesChange(
                    mProject.ProjectProps, newProps);
                ChangeSet cs = new ChangeSet(uc);
                ApplyUndoableChanges(cs);
            }
        }

        public bool CanEditStatusFlags() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Single line, must be code or a RegWidth directive.
            return (SelectionAnalysis.mLineType == LineListGen.Line.Type.Code ||
                SelectionAnalysis.mLineType == LineListGen.Line.Type.RegWidthDirective);
        }

        public void EditStatusFlags() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;

            EditStatusFlags dlg = new EditStatusFlags(mMainWin,
                mProject.StatusFlagOverrides[offset], mProject.CpuDef.HasEmuFlag);
            if (dlg.ShowDialog() != true) {
                return;
            }

            if (dlg.FlagValue != mProject.StatusFlagOverrides[offset]) {
                UndoableChange uc = UndoableChange.CreateStatusFlagChange(offset,
                    mProject.StatusFlagOverrides[offset], dlg.FlagValue);
                ChangeSet cs = new ChangeSet(uc);
                ApplyUndoableChanges(cs);
            }
        }

        public bool CanEditVisualizationSet() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Single line, must be a visualization set.
            LineListGen.Line.Type lineType = SelectionAnalysis.mLineType;
            return (lineType == LineListGen.Line.Type.VisualizationSet ||
                    lineType == LineListGen.Line.Type.Code ||
                    lineType == LineListGen.Line.Type.Data);
        }

        public void EditVisualizationSet() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            int offset = CodeLineList[selIndex].FileOffset;
            mProject.VisualizationSets.TryGetValue(offset, out VisualizationSet curVisSet);

            EditVisualizationSet dlg = new EditVisualizationSet(mMainWin, mProject,
                mFormatter, curVisSet, offset);
            if (dlg.ShowDialog() != true) {
                return;
            }
            VisualizationSet newSet = dlg.NewVisSet;
            if (newSet.Count == 0) {
                // empty sets are deleted
                newSet = null;
            }
            if (curVisSet != newSet) {
                ChangeSet cs = new ChangeSet(1);

                // New table, edited in place, or deleted.
                UndoableChange uc = UndoableChange.CreateVisualizationSetChange(offset,
                    curVisSet, newSet);
                //Debug.WriteLine("Change " + curVisSet + " to " + newSet);
                cs.Add(uc);

                // And now the messy bit.  If Visualizations were removed, we need to purge
                // them from any animations that reference them.  The edit dialog took care
                // of this for animations in the same set, but we need to check other sets.
                foreach (KeyValuePair<int, VisualizationSet> kvp in mProject.VisualizationSets) {
                    if (kvp.Value == curVisSet) {
                        continue;
                    }

                    VisualizationSet stripSet;
                    if (VisualizationSet.StripEntriesFromAnimations(kvp.Value, dlg.RemovedSerials,
                            out stripSet)) {
                        if (stripSet.Count == 0) {
                            stripSet = null;
                        }
                        uc = UndoableChange.CreateVisualizationSetChange(kvp.Key,
                            kvp.Value, stripSet);
                        cs.Add(uc);
                        Debug.WriteLine("Also updating visSet at +" + kvp.Key.ToString("x6"));
                    }
                }

                ApplyUndoableChanges(cs);
            } else {
                Debug.WriteLine("No change to VisualizationSet");
            }
        }

        public void Export() {
            string outName;
            if (string.IsNullOrEmpty(mProjectPathName)) {
                outName = Path.GetFileName(mDataPathName);
            } else {
                outName = Path.GetFileName(mProjectPathName);
            }

            Export dlg = new Export(mMainWin, outName);
            if (dlg.ShowDialog() == false) {
                return;
            }

            int[] rightWidths = new int[] {
                dlg.AsmLabelColWidth, dlg.AsmOpcodeColWidth,
                dlg.AsmOperandColWidth, dlg.AsmCommentColWidth
            };
            Exporter eport = new Exporter(mProject, CodeLineList, mFormatter,
                dlg.ColFlags, rightWidths);
            eport.IncludeNotes = dlg.IncludeNotes;
            eport.GenerateImageFiles = dlg.GenerateImageFiles;
            eport.LongLabelNewLine = dlg.LongLabelNewLine;
            if (dlg.SelectionOnly) {
                DisplayListSelection selection = mMainWin.CodeDisplayList.SelectedIndices;
                if (selection.Count == 0) {
                    // no selection == select all
                    selection = null;
                }
                eport.Selection = selection;
            }

            if (dlg.GenType == WpfGui.Export.GenerateFileType.Html) {
                // Generating wireframe animations can be slow, so we need to use a
                // progress dialog.
                eport.OutputToHtml(mMainWin, dlg.PathName, dlg.OverwriteCss);
            } else {
                // Text output is generally very fast.  Put up a wait cursor just in case.
                try {
                    Mouse.OverrideCursor = Cursors.Wait;
                    eport.OutputToText(dlg.PathName, dlg.TextModeCsv);
                } finally {
                    Mouse.OverrideCursor = null;
                }
            }
        }

        public void Find() {
            FindBox dlg = new FindBox(mMainWin, mFindString);
            if (dlg.ShowDialog() == true) {
                mFindString = dlg.TextToFind;
                mFindStartIndex = -1;
                FindText(dlg.IsBackward);
            }
        }

        public void FindNext() {
            FindText(false);
        }

        public void FindPrevious() {
            FindText(true);
        }

        private void FindText(bool goBackward) {
            if (string.IsNullOrEmpty(mFindString)) {
                return;
            }
            int incr = goBackward ? -1 : 1;

            // If we reversed direction, reset the "start index" so we don't tell the user
            // we've wrapped around.
            if (mFindBackward != goBackward) {
                mFindStartIndex = -1;
                mFindBackward = goBackward;
            }

            // Start from the topmost selected line, or the start of the file if nothing
            // is selected.
            // TODO(maybe): if multiple lines are selected, search only within the selected set.
            int index = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (index < 0) {
                index = 0;
            }

            // Start one past the selected item.
            index += incr;
            if (index == CodeLineList.Count) {
                index = 0;
            } else if (index == -1) {
                index = CodeLineList.Count - 1;
            }
            //Debug.WriteLine("FindText index=" + index + " start=" + mFindStartIndex +
            //    " str=" + mFindString);
            while (index != mFindStartIndex) {
                if (mFindStartIndex < 0) {
                    // need to latch this inside the loop so the initial test doesn't fail
                    mFindStartIndex = index;
                }

                string searchStr = CodeLineList.GetSearchString(index);
                int matchPos = searchStr.IndexOf(mFindString,
                    StringComparison.InvariantCultureIgnoreCase);
                if (matchPos >= 0) {
                    //Debug.WriteLine("Match " + index + ": " + searchStr);
                    mMainWin.CodeListView_EnsureVisible(index);
                    mMainWin.CodeListView_SelectRange(index, 1);
                    return;
                }

                index += incr;
                if (index == CodeLineList.Count) {
                    index = 0;
                } else if (index == -1) {
                    index = CodeLineList.Count - 1;
                }
            }

            // Announce that we've wrapped around, then clear the start index.
            MessageBox.Show(Res.Strings.FIND_REACHED_START,
                Res.Strings.FIND_REACHED_START_CAPTION, MessageBoxButton.OK,
                MessageBoxImage.Information);
            mFindStartIndex = -1;

            mMainWin.CodeListView_Focus();
        }

        public bool CanFormatAsWord() {
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // This is insufficient -- we need to know how many bytes are selected, and
            // whether they're already formatted as multi-byte items.  Too expensive to
            // deal with here, so we'll need to show failure dialogs instead (ugh).
            return (counts.mDataLines > 0 && counts.mCodeLines == 0);
        }

        public void FormatAsWord() {
            TypedRangeSet trs = GroupedOffsetSetFromSelected();
            if (trs.Count == 0) {
                Debug.Assert(false, "nothing to edit");         // shouldn't happen
                return;
            }

            // If the user has only selected a single byte, we want to add the following byte
            // to the selection, then proceed as usual.  We can't simply modify the ListView
            // selection because the following item might be an auto-detected string or fill,
            // and we'd be adding multiple bytes.  We have to be careful when grabbing the byte
            // in case there's a region-split at that point (e.g. user label or .ORG).
            //
            // We could expand this to allow multiple regions, each of which is a single byte,
            // but we'd need to deal with the case where the user selects two adjacent bytes that
            // cross a region boundary.
            if (trs.RangeCount == 1) {
                // Exactly one range entry.  Check its size.
                IEnumerator<TypedRangeSet.TypedRange> checkIter = trs.RangeListIterator;
                checkIter.MoveNext();
                TypedRangeSet.TypedRange rng = checkIter.Current;
                if (rng.Low == rng.High && rng.Low < mProject.FileDataLength - 1) {
                    // Single byte selected.  Check to see if it's okay to grab the next byte.
                    Anattrib thisAttr = mProject.GetAnattrib(rng.Low);
                    Debug.Assert(thisAttr.DataDescriptor.Length == 1);

                    int nextOffset = rng.Low + 1;
                    Anattrib nextAttr = mProject.GetAnattrib(nextOffset);
                    // This must match what GroupedOffsetSetFromSelected() does.
                    if (!mProject.UserLabels.ContainsKey(nextOffset) &&
                            !mProject.HasCommentNoteOrVis(nextOffset) &&
                            mProject.AddrMap.IsSingleAddrRange(nextOffset - 1, 2)) {
                        // Good to go.
                        Debug.WriteLine("Grabbing second byte from +" + nextOffset.ToString("x6"));
                        trs.Add(nextOffset, rng.Type);
                    }
                }
            }

            // Confirm that every selected byte is a single-byte data item (either set by
            // the user or as part of the uncategorized data scan).
            foreach (TypedRangeSet.Tuple tup in trs) {
                FormatDescriptor dfd = mProject.GetAnattrib(tup.Value).DataDescriptor;
                if (dfd != null && dfd.Length != 1) {
                    Debug.WriteLine("Can't format as word: offset +" + tup.Value.ToString("x6") +
                        " has len=" + dfd.Length + " (must be 1)");
                    MessageBox.Show(Res.Strings.INVALID_FORMAT_WORD_SEL_NON1,
                        Res.Strings.INVALID_FORMAT_WORD_SEL_CAPTION,
                        MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
            }

            // Confirm that, in each region, an even number of bytes are selected.
            IEnumerator<TypedRangeSet.TypedRange> rngIter = trs.RangeListIterator;
            while (rngIter.MoveNext()) {
                TypedRangeSet.TypedRange rng = rngIter.Current;
                int rangeLen = rng.High - rng.Low + 1;
                if ((rangeLen & 0x01) != 0) {
                    string msg = string.Format(Res.Strings.INVALID_FORMAT_WORD_SEL_UNEVEN_FMT,
                        trs.RangeCount);
                    MessageBox.Show(msg,
                        Res.Strings.INVALID_FORMAT_WORD_SEL_CAPTION,
                        MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
            }

            // Selection is good, generate changes.
            SortedList<int, FormatDescriptor> newFmts = new SortedList<int, FormatDescriptor>();
            rngIter.Reset();
            FormatDescriptor newDfd = FormatDescriptor.Create(2, FormatDescriptor.Type.NumericLE,
                FormatDescriptor.SubType.None);
            while (rngIter.MoveNext()) {
                TypedRangeSet.TypedRange rng = rngIter.Current;
                for (int i = rng.Low; i <= rng.High; i += 2) {
                    newFmts.Add(i, newDfd);
                }
            }

            ChangeSet cs = mProject.GenerateFormatMergeSet(newFmts);
            if (cs.Count != 0) {
                ApplyUndoableChanges(cs);
            }
        }

        public bool CanFormatAddressTable() {
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            // Must be at least one line of data, and no code.  Note this is lines, not bytes,
            // so we can't screen out single-byte lines without additional work.
            return (counts.mDataLines > 0 && counts.mCodeLines == 0);
        }

        public void FormatAddressTable() {
            TypedRangeSet trs = GroupedOffsetSetFromSelected();
            if (trs.Count == 0) {
                // shouldn't happen
                Debug.Assert(false, "FormatSplitAddressTable found nothing to edit");
                return;
            }

            FormatAddressTable dlg = new FormatAddressTable(mMainWin, mProject, trs,
                mFormatter);

            dlg.ShowDialog();
            if (dlg.DialogResult != true) {
                return;
            }

            // Start with the format descriptors.
            ChangeSet cs = mProject.GenerateFormatMergeSet(dlg.NewFormatDescriptors);

            // Add in the user labels.
            foreach (KeyValuePair<int, Symbol> kvp in dlg.NewUserLabels) {
                Symbol oldUserValue = null;
                if (mProject.UserLabels.ContainsKey(kvp.Key)) {
                    Debug.Assert(false, "should not be replacing label");
                    oldUserValue = mProject.UserLabels[kvp.Key];
                }
                UndoableChange uc = UndoableChange.CreateLabelChange(kvp.Key,
                    oldUserValue, kvp.Value);
                cs.Add(uc);
            }

            // Apply code hints.
            if (dlg.WantCodeHints) {
                TypedRangeSet newSet = new TypedRangeSet();
                TypedRangeSet undoSet = new TypedRangeSet();

                foreach (int offset in dlg.AllTargetOffsets) {
                    if (!mProject.GetAnattrib(offset).IsInstruction) {
                        CodeAnalysis.TypeHint oldType = mProject.TypeHints[offset];
                        if (oldType == CodeAnalysis.TypeHint.Code) {
                            continue;       // already set
                        }
                        undoSet.Add(offset, (int)oldType);
                        newSet.Add(offset, (int)CodeAnalysis.TypeHint.Code);
                    }
                }
                if (newSet.Count != 0) {
                    cs.Add(UndoableChange.CreateTypeHintChange(undoSet, newSet));
                }
            }

            // Finally, apply the change.
            if (cs.Count != 0) {
                ApplyUndoableChanges(cs);
            } else {
                Debug.WriteLine("No changes found");
            }
        }

        public void Goto() {
            int index = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (index < 0) {
                index = mMainWin.CodeListView_GetTopIndex();    // nothing selected
            }
            int offset = CodeLineList[index].FileOffset;

            GotoBox dlg = new GotoBox(mMainWin, mProject, offset, mFormatter);
            if (dlg.ShowDialog() == true) {
                GoToLocation(new NavStack.Location(dlg.TargetOffset, 0, false),
                    GoToMode.JumpToCodeData, true);
                mMainWin.CodeListView_Focus();
            }
        }

        public enum GoToMode { Unknown = 0, JumpToCodeData, JumpToNote, JumpToAdjIndex };
        /// <summary>
        /// Moves the view and selection to the specified offset.  We want to select stuff
        /// differently if we're jumping to a note vs. jumping to an instruction.
        /// </summary>
        /// <param name="gotoOffset">Offset to jump to.</param>
        /// <param name="doPush">If set, push new offset onto navigation stack.</param>
        public void GoToLocation(NavStack.Location loc, GoToMode mode, bool doPush) {
            NavStack.Location prevLoc = GetCurrentlySelectedLocation();
            //Debug.WriteLine("GoToLocation: " + loc + " mode=" + mode + " doPush=" + doPush +
            //    " (curLoc=" + prevLoc + ")");

            // Avoid pushing multiple copies of the same address on.  This doesn't quite work
            // because we can't compare the LineDelta without figuring out JumpToCodeData first.
            // If we're sitting in a long comment or LvTable and the user double-clicks on the
            // entry in the symbol table for the current offset, we want to move the selection,
            // so we don't want to bail out if the offset matches.  Easiest thing to do is to
            // do the move but not push it.
            bool jumpToNote = (mode == GoToMode.JumpToNote);
            if (loc.Offset == prevLoc.Offset && jumpToNote == prevLoc.IsNote) {
                // we're jumping to ourselves?
                if (doPush) {
                    Debug.WriteLine("Ignoring push for goto to current offset");
                    doPush = false;
                }
            }

            int topLineIndex = CodeLineList.FindLineIndexByOffset(loc.Offset);
            if (topLineIndex < 0) {
                Debug.Assert(false, "failed goto offset +" + loc.Offset.ToString("x6"));
                return;
            }
            int lastLineIndex;
            if (mode == GoToMode.JumpToNote) {
                // Select all note lines, disregard the rest.
                while (CodeLineList[topLineIndex].LineType != LineListGen.Line.Type.Note) {
                    topLineIndex++;
                    Debug.Assert(CodeLineList[topLineIndex].FileOffset == loc.Offset);
                }
                lastLineIndex = topLineIndex + 1;
                while (lastLineIndex < CodeLineList.Count &&
                        CodeLineList[lastLineIndex].LineType == LineListGen.Line.Type.Note) {
                    lastLineIndex++;
                }
            } else if (loc.Offset < 0) {
                // This is the offset of the header comment or a .EQ directive. Don't mess with it.
                lastLineIndex = topLineIndex + 1;
            } else if (mode == GoToMode.JumpToCodeData) {
                // Advance to the code or data line.
                while (CodeLineList[topLineIndex].LineType != LineListGen.Line.Type.Code &&
                        CodeLineList[topLineIndex].LineType != LineListGen.Line.Type.Data) {
                    topLineIndex++;
                }

                lastLineIndex = topLineIndex + 1;
            } else if (mode == GoToMode.JumpToAdjIndex) {
                // Adjust the line position by the line delta.  If the adjustment moves us to
                // a different element, ignore the adjustment.
                if (CodeLineList[topLineIndex].FileOffset ==
                        CodeLineList[topLineIndex + loc.LineDelta].FileOffset) {
                    topLineIndex += loc.LineDelta;
                }
                lastLineIndex = topLineIndex + 1;
            } else {
                Debug.Assert(false);
                lastLineIndex = topLineIndex + 1;
            }

            // Make sure the item is visible.  For notes, this can span multiple lines.
            mMainWin.CodeListView_EnsureVisible(lastLineIndex - 1);
            mMainWin.CodeListView_EnsureVisible(topLineIndex);

            // Update the selection.
            mMainWin.CodeListView_SelectRange(topLineIndex, lastLineIndex - topLineIndex);

            // Set the focus to the first selected item.  The focus is used by the keyboard
            // handler to decide what the up/down arrows select next.
            mMainWin.CodeListView_SetSelectionFocus();

            if (doPush) {
                // Update the back stack and associated controls.
                mNavStack.Push(prevLoc);
            }
        }

        /// <summary>
        /// Moves the view and selection to the definition of a local variable.
        /// </summary>
        /// <param name="offset">Offset at which the variable was referenced.</param>
        /// <param name="symRef">Reference to variable.</param>
        public void GoToVarDefinition(int offset, WeakSymbolRef symRef, bool doPush) {
            Debug.Assert(offset >= 0);
            Debug.Assert(symRef.IsVariable);

            LocalVariableLookup lvLookup = new LocalVariableLookup(mProject.LvTables, mProject,
                null, false, false);
            int varOffset = lvLookup.GetDefiningTableOffset(offset, symRef);
            if (varOffset < 0) {
                Debug.WriteLine("Local variable not found; offset=" + offset + " ref=" + symRef);
                return;
            }

            // Find the actual symbol definition.
            LocalVariableTable lvTable = mProject.LvTables[varOffset];
            DefSymbol foundSym = lvTable.GetByLabel(symRef.Label);
            if (foundSym == null) {
                // shouldn't be possible
                Debug.WriteLine("Did not find " + symRef.Label + " in expected table");
                Debug.Assert(false);
                return;
            }

            // We have the offset to which the local variable table is bound.  We need to
            // walk down until we find the variable definitions, and find the line with the
            // matching symbol.
            //
            // We're comparing to the formatted strings -- safer than trying to find the symbol
            // in the table and then guess at how the table arranges itself for display -- so we
            // need to compare the formatted form of the label.
            //
            // We need to use GenerateDisplayLabel() because the symbol might have an annotation.
            string cmpStr = mFormatter.FormatVariableLabel(
                foundSym.GenerateDisplayLabel(mFormatter));
            int lineIndex = CodeLineList.FindLineIndexByOffset(varOffset);
            while (lineIndex < mProject.FileDataLength) {
                LineListGen.Line line = CodeLineList[lineIndex];
                if (line.FileOffset != varOffset) {
                    // we've gone too far
                    Debug.WriteLine("ran out of LV table");
                    return;
                }

                if (line.LineType == LineListGen.Line.Type.LocalVariableTable) {
                    DisplayList.FormattedParts parts = CodeLineList.GetFormattedParts(lineIndex);
                    if (cmpStr.Equals(parts.Label)) {
                        // Eureka
                        NavStack.Location prevLoc = GetCurrentlySelectedLocation();

                        mMainWin.CodeListView_EnsureVisible(lineIndex);

                        // Update the selection.
                        mMainWin.CodeListView_SelectRange(lineIndex, 1);

                        if (doPush) {
                            // Update the back stack and associated controls.
                            mNavStack.Push(prevLoc);
                        }

                        return;
                    } else {
                        //Debug.WriteLine("Var: '" + cmpStr + "' != '" + parts.Label + "'");
                    }
                }

                lineIndex++;
            }
        }

        public bool CanJumpToOperand() {
            if (SelectionAnalysis.mNumItemsSelected != 1) {
                return false;
            }
            LineListGen.Line.Type lineType = SelectionAnalysis.mLineType;
            if (lineType != LineListGen.Line.Type.Code &&
                    lineType != LineListGen.Line.Type.Data) {
                return false;
            }

            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            Debug.Assert(selIndex >= 0);
            return JumpToOperandTarget(CodeLineList[selIndex], true);
        }

        public void JumpToOperand() {
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            Debug.Assert(selIndex >= 0);
            JumpToOperandTarget(CodeLineList[selIndex], false);
        }

        /// <summary>
        /// Calculates the currently-selected location.
        /// </summary>
        /// <remarks>
        /// This is done whenever we jump somewhere else.  For the most part we'll be in a
        /// line of code, jumping when an operand or reference is double-clicked, but we might
        /// be in the middle of a long comment when a symbol is double-clicked or the
        /// nav-forward arrow is clicked.  The most interesting case is when a reference for
        /// a local variable table entry is double-clicked, since we want to be sure that we
        /// return to the correct entry in the LvTable (assuming it still exists).
        /// </remarks>
        /// <returns>Returns the location.</returns>
        private NavStack.Location GetCurrentlySelectedLocation() {
            int index = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (index < 0) {
                // nothing selected, use top instead
                index = mMainWin.CodeListView_GetTopIndex();
            }
            int offset = CodeLineList[index].FileOffset;
            int lineDelta = index - CodeLineList.FindLineIndexByOffset(offset);
            bool isNote = (CodeLineList[index].LineType == LineListGen.Line.Type.Note);
            return new NavStack.Location(offset, lineDelta, isNote);
        }

        public void GotoLastChange() {
            ChangeSet cs = mProject.GetTopChange();
            Debug.Assert(cs.Count > 0);

            // Get the offset from the first change in the set.  Ignore the rest.
            UndoableChange uc = cs[0];
            int offset;
            bool isNote = false;
            if (uc.HasOffset) {
                offset = uc.Offset;

                // If we altered a Note, and didn't remove it, jump to the note instead of
                // the nearby code/data.
                //
                // TODO(someday): we can do similar things for comment edits, e.g. if it's
                // SetLongComment we can find the line on which the comment starts and
                // pass that as a line delta.
                if (uc.Type == UndoableChange.ChangeType.SetNote &&
                        uc.NewValue != null) {
                    isNote = true;
                }
            } else if (uc.Type == UndoableChange.ChangeType.SetProjectProperties) {
                // some chance it modified the EQU statements... jump there
                offset = 0;
            } else if (uc.Type == UndoableChange.ChangeType.SetTypeHint) {
                TypedRangeSet newSet = (TypedRangeSet)uc.NewValue;
                if (newSet.Count == 0) {
                    // unexpected
                    Debug.Assert(false);
                    return;
                }

                // Get the offset of the first entry.
                IEnumerator<TypedRangeSet.Tuple> iter =
                    (IEnumerator<TypedRangeSet.Tuple>)newSet.GetEnumerator();
                iter.MoveNext();
                TypedRangeSet.Tuple firstOffset = iter.Current;
                offset = firstOffset.Value;
            } else {
                Debug.Assert(false);
                return;
            }

            if (isNote) {
                GoToLocation(new NavStack.Location(offset, 0, true),
                    GoToMode.JumpToNote, true);
            } else {
                GoToLocation(new NavStack.Location(offset, 0, false),
                    GoToMode.JumpToCodeData, true);
            }
        }

        public bool CanNavigateBackward() {
            return mNavStack.HasBackward;
        }
        public void NavigateBackward() {
            Debug.Assert(mNavStack.HasBackward);
            NavStack.Location backLoc = mNavStack.MoveBackward(GetCurrentlySelectedLocation());
            GoToLocation(backLoc,
                backLoc.IsNote ? GoToMode.JumpToNote : GoToMode.JumpToAdjIndex, false);
        }

        public bool CanNavigateForward() {
            return mNavStack.HasForward;
        }
        public void NavigateForward() {
            Debug.Assert(mNavStack.HasForward);
            NavStack.Location fwdLoc = mNavStack.MoveForward(GetCurrentlySelectedLocation());
            GoToLocation(fwdLoc,
                fwdLoc.IsNote ? GoToMode.JumpToNote : GoToMode.JumpToAdjIndex, false);
        }

        /// <summary>
        /// Scrolls the code list so that the specified label is shown.
        /// </summary>
        /// <param name="sym">Label symbol.</param>
        public void GoToLabel(Symbol sym) {
            if (sym.IsInternalLabel) {
                int offset = mProject.FindLabelOffsetByName(sym.Label);
                if (offset >= 0) {
                    GoToLocation(new NavStack.Location(offset, 0, false),
                        GoToMode.JumpToCodeData, true);
                } else {
                    Debug.WriteLine("DClick symbol: " + sym + ": label not found");
                }
            } else {
                Debug.WriteLine("DClick symbol: " + sym + ": not label");
            }
        }

        public void SelectionChanged() {
            SelectionAnalysis = UpdateSelectionState();

            UpdateReferencesPanel();
            UpdateInfoPanel();
            UpdateSelectionHighlight();
        }

        /// <summary>
        /// Gathered facts about the current selection.  Recalculated whenever the selection
        /// changes.
        /// </summary>
        public class SelectionState {
            // Number of selected items or lines, reduced.  This will be:
            //  0 if no lines are selected
            //  1 if a single *item* is selected (regardless of number of lines)
            //  >1 if more than one item is selected (exact value not specified)
            public int mNumItemsSelected;

            // Single selection: the type of line selected.  (Multi-sel: Unclassified)
            public LineListGen.Line.Type mLineType;

            // Single selection: is line an instruction with an operand.  (Multi-sel: False)
            public bool mIsInstructionWithOperand;

            // Single selection: is line an EQU directive for a project symbol.  (Multi-sel: False)
            public bool mIsProjectSymbolEqu;

            // Some totals.
            public EntityCounts mEntityCounts;

            public SelectionState() {
                mLineType = LineListGen.Line.Type.Unclassified;
                mEntityCounts = new EntityCounts();
            }
        }

        /// <summary>
        /// Updates Actions menu enable states when the selection changes.
        /// </summary>
        ///   is selected.</param>
        public SelectionState UpdateSelectionState() {
            int selCount = mMainWin.CodeListView_GetSelectionCount();
            //Debug.WriteLine("UpdateSelectionState: selCount=" + selCount);

            SelectionState state = new SelectionState();

            // Use IsSingleItemSelected(), rather than just checking sel.Count, because we
            // want the user to be able to e.g. EditData on a multi-line string even if all
            // lines in the string are selected.
            if (selCount < 0) {
                // nothing selected, leave everything set to false / 0
                state.mEntityCounts = new EntityCounts();
            } else if (IsSingleItemSelected()) {
                int firstIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
                state.mNumItemsSelected = 1;
                state.mEntityCounts = GatherEntityCounts(firstIndex);
                LineListGen.Line line = CodeLineList[firstIndex];
                state.mLineType = line.LineType;

                state.mIsInstructionWithOperand = (line.LineType == LineListGen.Line.Type.Code &&
                    mProject.GetAnattrib(line.FileOffset).IsInstructionWithOperand);
                if (line.LineType == LineListGen.Line.Type.EquDirective) {
                    // See if this EQU directive is for a project symbol.
                    int symIndex = LineListGen.DefSymIndexFromOffset(line.FileOffset);
                    DefSymbol defSym = mProject.ActiveDefSymbolList[symIndex];
                    state.mIsProjectSymbolEqu = (defSym.SymbolSource == Symbol.Source.Project);
                }
            } else {
                state.mNumItemsSelected = 2;
                state.mEntityCounts = GatherEntityCounts(-1);
            }

            return state;
        }

        /// <summary>
        /// Entity count collection, for GatherEntityCounts.
        /// </summary>
        public class EntityCounts {
            public int mCodeLines;
            public int mDataLines;
            public int mBlankLines;
            public int mControlLines;

            public int mCodeHints;
            public int mDataHints;
            public int mInlineDataHints;
            public int mNoHints;
        };

        /// <summary>
        /// Gathers a count of different line types and hints that are currently selected.
        /// </summary>
        /// <param name="singleLineIndex">If a single line is selected, pass the index in.
        ///   Otherwise, pass -1 to traverse the entire line list.</param>
        /// <returns>Object with computed totals.</returns>
        private EntityCounts GatherEntityCounts(int singleLineIndex) {
            //DateTime startWhen = DateTime.Now;
            int codeLines, dataLines, blankLines, controlLines;
            int codeHints, dataHints, inlineDataHints, noHints;
            codeLines = dataLines = blankLines = controlLines = 0;
            codeHints = dataHints = inlineDataHints = noHints = 0;

            int startIndex, endIndex;
            if (singleLineIndex < 0) {
                startIndex = 0;
                endIndex = mMainWin.CodeDisplayList.Count - 1;
            } else {
                startIndex = endIndex = singleLineIndex;
            }

            for (int i = startIndex; i <= endIndex; i++) {
                if (!mMainWin.CodeDisplayList.SelectedIndices[i]) {
                    // not selected, ignore
                    continue;
                }
                LineListGen.Line line = CodeLineList[i];
                switch (line.LineType) {
                    case LineListGen.Line.Type.Code:
                        codeLines++;
                        break;
                    case LineListGen.Line.Type.Data:
                        dataLines++;
                        break;
                    case LineListGen.Line.Type.Blank:
                        // Don't generally care how many blank lines there are, but we do want
                        // to exclude them from the other categories: if we have nothing but
                        // blank lines, there's nothing to do.
                        blankLines++;
                        break;
                    default:
                        // These are only editable as single-line items.  We do allow mass
                        // code hint selection to include them (they will be ignored).
                        // org, equ, rwid, long comment...
                        controlLines++;
                        break;
                }

                // A single line can span multiple offsets, each of which could have a
                // different hint.  Note the code/data hints are only applied to the first
                // byte of each selected line, so we're not quite in sync with that.
                //
                // For multi-line items, the OffsetSpan of the first item covers the entire
                // item (it's the same for all Line instances), so we only want to do this for
                // the first entry.
                if (line.SubLineIndex == 0) {
                    for (int offset = line.FileOffset; offset < line.FileOffset + line.OffsetSpan;
                            offset++) {
                        switch (mProject.TypeHints[offset]) {
                            case CodeAnalysis.TypeHint.Code:
                                codeHints++;
                                break;
                            case CodeAnalysis.TypeHint.Data:
                                dataHints++;
                                break;
                            case CodeAnalysis.TypeHint.InlineData:
                                inlineDataHints++;
                                break;
                            case CodeAnalysis.TypeHint.NoHint:
                                noHints++;
                                break;
                            default:
                                Debug.Assert(false);
                                break;
                        }
                    }
                }
            }

            //Debug.WriteLine("GatherEntityCounts (start=" + startIndex + " end=" + endIndex +
            //    " len=" + mMainWin.CodeDisplayList.Count +
            //    ") took " + (DateTime.Now - startWhen).TotalMilliseconds + " ms");

            return new EntityCounts() {
                mCodeLines = codeLines,
                mDataLines = dataLines,
                mBlankLines = blankLines,
                mControlLines = controlLines,
                mCodeHints = codeHints,
                mDataHints = dataHints,
                mInlineDataHints = inlineDataHints,
                mNoHints = noHints
            };
        }

        /// <summary>
        /// Determines whether the current selection spans a single item.  This could be a
        /// single-line item or a multi-line item.
        /// </summary>
        private bool IsSingleItemSelected() {
            int firstIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            if (firstIndex < 0) {
                // empty selection
                return false;
            }

            int lastIndex = mMainWin.CodeListView_GetLastSelectedIndex();
            if (lastIndex == firstIndex) {
                // only one line is selected
                return true;
            }

            // Just check the first and last entries to see if they're the same.
            LineListGen.Line firstItem = CodeLineList[firstIndex];
            LineListGen.Line lastItem = CodeLineList[lastIndex];
            if (firstItem.FileOffset == lastItem.FileOffset &&
                    firstItem.LineType == lastItem.LineType) {
                return true;
            }
            return false;
        }

        private bool mUpdatingSelectionHighlight;       // recursion guard
        /// <summary>
        /// Updates the selection highlight.  When a code item with an operand offset is
        /// selected, such as a branch, we want to highlight the address and label of the
        /// target.
        /// </summary>
        private void UpdateSelectionHighlight() {
            int targetIndex = FindSelectionHighlight();

            if (mTargetHighlightIndex != targetIndex && !mUpdatingSelectionHighlight) {
                Debug.WriteLine("Target highlight moving from " + mTargetHighlightIndex +
                    " to " + targetIndex);

                // The highlight is currently implemented by modifying the item in the
                // display list.  Because those items are immutable, we have to remove the
                // old and add a new.  The WPF ListView maintains its selection by object
                // reference, so replacing an item requires removing the old item from the
                // selection set and adding it to the new.
                //
                // So if a line references itself (like the ZipGS cache conditioner loop does),
                // it will be the selected line while we're doing this little dance.  When the
                // calls below update the selection, this method will be called again. This
                // turns into infinite recursion.
                mUpdatingSelectionHighlight = true;
                mMainWin.CodeListView_RemoveSelectionHighlight(mTargetHighlightIndex);
                mMainWin.CodeListView_AddSelectionHighlight(targetIndex);
                mUpdatingSelectionHighlight = false;

                mTargetHighlightIndex = targetIndex;
            }
        }

        private int FindSelectionHighlight() {
            if (mMainWin.CodeListView_GetSelectionCount() != 1) {
                return -1;
            }
            int selIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            LineListGen.Line line = CodeLineList[selIndex];
            if (!line.IsCodeOrData) {
                return -1;
            }
            Debug.Assert(line.FileOffset >= 0);

            // Does this have an operand with an in-file target offset?
            Anattrib attr = mProject.GetAnattrib(line.FileOffset);
            if (attr.OperandOffset >= 0) {
                return CodeLineList.FindCodeDataIndexByOffset(attr.OperandOffset);
            } else if (attr.IsDataStart || attr.IsInlineDataStart) {
                // If it's an Address or Symbol, we can try to resolve
                // the value.
                int operandOffset = DataAnalysis.GetDataOperandOffset(mProject, line.FileOffset);
                if (operandOffset >= 0) {
                    return CodeLineList.FindCodeDataIndexByOffset(operandOffset);
                }
            }
            return -1;
        }

        public void ShowHexDump() {
            if (mHexDumpDialog == null) {
                // Create and show modeless dialog.  This one is "always on top" by default,
                // to allow the user to click around to various points.  Note that "on top"
                // means on top of *everything*.  We create this without an owner so that,
                // when it's not on top, it can sit behind the main app window until you
                // double-click something else.
                mHexDumpDialog = new Tools.WpfGui.HexDumpViewer(null,
                    mProject.FileData, mFormatter);
                mHexDumpDialog.Closing += (sender, e) => {
                    Debug.WriteLine("Hex dump dialog closed");
                    //showHexDumpToolStripMenuItem.Checked = false;
                    mHexDumpDialog = null;
                };
                mHexDumpDialog.Topmost = true;
                mHexDumpDialog.Show();
            }

            // Bring it to the front of the window stack.  This also transfers focus to the
            // window.
            mHexDumpDialog.Activate();

            // Set the dialog's position.
            if (mMainWin.CodeListView_GetSelectionCount() > 0) {
                int firstIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
                int lastIndex = mMainWin.CodeListView_GetLastSelectedIndex();
                // offsets can be < 0 if they've selected EQU statements
                int firstOffset = Math.Max(0, CodeLineList[firstIndex].FileOffset);
                int lastOffset = Math.Max(firstOffset, CodeLineList[lastIndex].FileOffset +
                    CodeLineList[lastIndex].OffsetSpan - 1);
                mHexDumpDialog.ShowOffsetRange(firstOffset, lastOffset);
            }
        }

        /// <summary>
        /// Handles the four Actions &gt; edit hint commands.
        /// </summary>
        /// <param name="hint">Type of hint to apply.</param>
        /// <param name="firstByteOnly">If set, only the first byte on each line is hinted.</param>
        public void MarkAsType(CodeAnalysis.TypeHint hint, bool firstByteOnly) {
            RangeSet sel;

            if (firstByteOnly) {
                sel = new RangeSet();
                foreach (int index in mMainWin.CodeDisplayList.SelectedIndices) {
                    int offset = CodeLineList[index].FileOffset;
                    if (offset >= 0) {
                        // Not interested in the header stuff for hinting.
                        sel.Add(offset);
                    }
                }
            } else {
                sel = OffsetSetFromSelected();
            }

            TypedRangeSet newSet = new TypedRangeSet();
            TypedRangeSet undoSet = new TypedRangeSet();

            foreach (int offset in sel) {
                if (offset < 0) {
                    // header comment
                    continue;
                }
                CodeAnalysis.TypeHint oldType = mProject.TypeHints[offset];
                if (oldType == hint) {
                    // no change, don't add to set
                    continue;
                }
                undoSet.Add(offset, (int)oldType);
                newSet.Add(offset, (int)hint);
            }
            if (newSet.Count == 0) {
                Debug.WriteLine("No changes found (" + hint + ", " + sel.Count + " offsets)");
                return;
            }

            UndoableChange uc = UndoableChange.CreateTypeHintChange(undoSet, newSet);
            ChangeSet cs = new ChangeSet(uc);

            ApplyUndoableChanges(cs);
        }

        /// <summary>
        /// Converts the set of selected items into a set of offsets.  If a line
        /// spans multiple offsets (e.g. a 3-byte instruction), offsets for every
        /// byte are included.
        /// 
        /// Boundaries such as labels and address changes are ignored.
        /// </summary>
        /// <returns>RangeSet with all offsets.</returns>
        private RangeSet OffsetSetFromSelected() {
            RangeSet rs = new RangeSet();

            foreach (int index in mMainWin.CodeDisplayList.SelectedIndices) {
                int offset = CodeLineList[index].FileOffset;

                // Mark every byte of an instruction or multi-byte data item --
                // everything that is represented by the line the user selected.
                int len;
                if (offset >= 0) {
                    len = mProject.GetAnattrib(offset).Length;
                } else {
                    // header area
                    len = 1;
                }
                Debug.Assert(len > 0);
                for (int i = offset; i < offset + len; i++) {
                    rs.Add(i);
                }
            }
            return rs;
        }

        /// <summary>
        /// Handles Help - Help
        /// </summary>
        public void ShowHelp() {
            HelpAccess.ShowHelp(HelpAccess.Topic.Contents);
        }

        /// <summary>
        /// Handles Help - About
        /// </summary>
        public void ShowAboutBox() {
            AboutBox dlg = new AboutBox(mMainWin);
            dlg.ShowDialog();
        }

        public void ToggleDataScan() {
            ProjectProperties oldProps = mProject.ProjectProps;
            ProjectProperties newProps = new ProjectProperties(oldProps);
            newProps.AnalysisParams.AnalyzeUncategorizedData =
                !newProps.AnalysisParams.AnalyzeUncategorizedData;
            UndoableChange uc = UndoableChange.CreateProjectPropertiesChange(oldProps, newProps);
            ApplyUndoableChanges(new ChangeSet(uc));
        }

        public bool CanToggleSingleByteFormat() {
            EntityCounts counts = SelectionAnalysis.mEntityCounts;
            return (counts.mDataLines > 0 && counts.mCodeLines == 0);
        }

        public void ToggleSingleByteFormat() {
            TypedRangeSet trs = GroupedOffsetSetFromSelected();
            if (trs.Count == 0) {
                Debug.Assert(false, "nothing to edit");         // shouldn't happen
                return;
            }

            // Check the format descriptor of the first selected offset.
            int firstOffset = -1;
            foreach (TypedRangeSet.Tuple tup in trs) {
                firstOffset = tup.Value;
                break;
            }
            Debug.Assert(mProject.GetAnattrib(firstOffset).IsDataStart ||
                mProject.GetAnattrib(firstOffset).IsInlineDataStart);
            bool toDefault = false;
            if (mProject.OperandFormats.TryGetValue(firstOffset, out FormatDescriptor curDfd)) {
                if (curDfd.FormatType == FormatDescriptor.Type.NumericLE &&
                        curDfd.FormatSubType == FormatDescriptor.SubType.None &&
                        curDfd.Length == 1) {
                    // Currently single-byte, toggle to default.
                    toDefault = true;
                }
            }

            // Iterate through the selected regions.
            SortedList<int, FormatDescriptor> newFmts = new SortedList<int, FormatDescriptor>();
            IEnumerator<TypedRangeSet.TypedRange> rngIter = trs.RangeListIterator;
            while (rngIter.MoveNext()) {
                TypedRangeSet.TypedRange rng = rngIter.Current;
                if (toDefault) {
                    // Create a single REMOVE descriptor that covers the full span.
                    FormatDescriptor newDfd = FormatDescriptor.Create(rng.High - rng.Low + 1,
                        FormatDescriptor.Type.REMOVE, FormatDescriptor.SubType.None);
                    newFmts.Add(rng.Low, newDfd);
                } else {
                    // Add individual single-byte format descriptors for everything.
                    FormatDescriptor newDfd = FormatDescriptor.Create(1,
                        FormatDescriptor.Type.NumericLE, FormatDescriptor.SubType.None);
                    for (int i = rng.Low; i <= rng.High; i++) {
                        newFmts.Add(i, newDfd);
                    }
                }
            }

            ChangeSet cs = mProject.GenerateFormatMergeSet(newFmts);
            if (cs.Count != 0) {
                ApplyUndoableChanges(cs);
            }
        }

        /// <summary>
        /// Converts the ListView's selected items into a set of offsets.  If a line
        /// spans multiple offsets (e.g. a 3-byte instruction), offsets for every
        /// byte are included.
        /// </summary>
        /// <remarks>
        /// Contiguous regions with user labels or address changes are split into
        /// independent regions by using a serial number for the range type.  Same for
        /// long comments and notes.
        ///
        /// We don't split based on existing data format items.  That would make it impossible
        /// to convert from (say) a collection of single bytes to a collection of double bytes
        /// or a string.  It should not be possible to select part of a formatted section,
        /// unless the user has been playing weird games with type hints to get overlapping
        /// format descriptors.
        ///
        /// The type values used in the TypedRangeSet may not be contiguous.  They're only
        /// there to create group separation from otherwise contiguous address ranges.
        /// </remarks>
        /// <returns>TypedRangeSet with all offsets.</returns>
        private TypedRangeSet GroupedOffsetSetFromSelected() {
            TypedRangeSet rs = new TypedRangeSet();
            int groupNum = 0;
            int expectedAddr = -1;

            DateTime startWhen = DateTime.Now;
            int prevOffset = -1;
            foreach (int index in mMainWin.CodeDisplayList.SelectedIndices) {
                // Don't add an offset to the set if the only part of it that is selected
                // is a directive or blank line.  We only care about file offsets, so skip
                // anything that isn't code or data.
                if (!CodeLineList[index].IsCodeOrData) {
                    continue;
                }

                int offset = CodeLineList[index].FileOffset;
                if (offset == prevOffset) {
                    // This is a continuation of a multi-line item like a string.  We've
                    // already accounted for all bytes associated with this offset.
                    continue;
                }
                Anattrib attr = mProject.GetAnattrib(offset);

                if (expectedAddr == -1) {
                    expectedAddr = attr.Address;
                }
                // Check for things that start a new group.
                if (attr.Address != expectedAddr) {
                    // For a contiguous selection, this should only happen if there's a .ORG
                    // address change.  For non-contiguous selection this is expected.  In the
                    // latter case, incrementing the group number is unnecessary but harmless
                    // (the TypedRangeSet splits at the gap).
                    //Debug.WriteLine("Address break: $" + attr.Address.ToString("x4") + " vs. $"
                    //    + expectedAddr.ToString("x4"));
                    expectedAddr = attr.Address;
                    groupNum++;
                } else if (offset > 0 && !mProject.AddrMap.IsSingleAddrRange(offset - 1, 2)) {
                    // Was the previous byte in a different address range?  This is only
                    // strictly necessary if the previous byte was in the selection set (which
                    // it won't be if the selection starts at the beginning of an address
                    // range), but bumping the group number is harmless if it wasn't.
                    groupNum++;
                } else if (mProject.UserLabels.ContainsKey(offset)) {
                    //if (mProject.GetAnattrib(offset).Symbol != null) {
                    // We consider auto labels when splitting regions for the data analysis,
                    // but I don't think we want to take them into account here.  The specific
                    // example that threw me was loading a 16-bit value from an address table.
                    // The code does "LDA table,X / STA / LDA table+1,X / STA", which puts auto
                    // labels at the first two addresses -- splitting the region.  That's good
                    // for the uncategorized data analyzer, but very annoying if you want to
                    // slap a 16-bit numeric format on all entries in a table.
                    groupNum++;
                } else if (mProject.HasCommentNoteOrVis(offset)) {
                    // Don't carry across a long comment, note, or visualization.
                    groupNum++;
                }

                // Mark every byte of an instruction or multi-byte data item --
                // everything that is represented by the line the user selected.  Control
                // statements and blank lines aren't relevant here, as we only care about
                // file offsets.
                int len = CodeLineList[index].OffsetSpan; // attr.Length;
                Debug.Assert(len > 0);
                for (int i = offset; i < offset + len; i++) {
                    rs.Add(i, groupNum);
                }
                // Advance the address.
                expectedAddr += len;

                prevOffset = offset;
            }
            Debug.WriteLine("Offset selection conv took " +
                (DateTime.Now - startWhen).TotalMilliseconds + " ms");
            return rs;
        }

        #endregion Main window UI event handlers


        #region References panel

        /// <summary>
        /// Updates the "references" panel to reflect the current selection.
        /// 
        /// The number of references to any given address should be relatively small, and
        /// won't change without a data refresh, so recreating the list every time shouldn't
        /// be a problem.
        /// </summary>
        private void UpdateReferencesPanel() {
            mMainWin.ReferencesList.Clear();

            if (mMainWin.CodeListView_GetSelectionCount() != 1) {
                // Nothing selected, or multiple lines selected.
                return;
            }
            int lineIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            LineListGen.Line.Type type = CodeLineList[lineIndex].LineType;
            if (type != LineListGen.Line.Type.Code &&
                    type != LineListGen.Line.Type.Data &&
                    type != LineListGen.Line.Type.EquDirective &&
                    type != LineListGen.Line.Type.LocalVariableTable) {
                // Code, data, and platform symbol EQUs have xrefs.
                return;
            }

            XrefSet xrefs;

            // Find the appropriate xref set.
            if (type == LineListGen.Line.Type.LocalVariableTable) {
                DefSymbol defSym = CodeLineList.GetLocalVariableFromLine(lineIndex);
                xrefs = (defSym == null) ? null : defSym.Xrefs;
            } else {
                int offset = CodeLineList[lineIndex].FileOffset;
                if (offset < 0) {
                    // EQU in header
                    int index = LineListGen.DefSymIndexFromOffset(offset);
                    DefSymbol defSym = mProject.ActiveDefSymbolList[index];
                    xrefs = defSym.Xrefs;
                } else {
                    xrefs = mProject.GetXrefSet(offset);
                }
            }
            if (xrefs == null || xrefs.Count == 0) {
                return;
            }

            // TODO(someday): localization
            Asm65.Formatter formatter = mFormatter;
            bool showBank = !mProject.CpuDef.HasAddr16;
            for (int i = 0; i < xrefs.Count; i++) {
                XrefSet.Xref xr = xrefs[i];

                string idxStr = string.Empty;
                string typeStr;
                switch (xr.Type) {
                    case XrefSet.XrefType.SubCallOp:
                        typeStr = "call ";
                        break;
                    case XrefSet.XrefType.BranchOp:
                        typeStr = "branch ";
                        break;
                    case XrefSet.XrefType.RefFromData:
                        typeStr = "data ";
                        break;
                    case XrefSet.XrefType.MemAccessOp:
                        switch (xr.AccType) {
                            case OpDef.MemoryEffect.Read:
                                typeStr = "read ";
                                break;
                            case OpDef.MemoryEffect.Write:
                                typeStr = "write ";
                                break;
                            case OpDef.MemoryEffect.ReadModifyWrite:
                                typeStr = "rmw ";
                                break;
                            case OpDef.MemoryEffect.None:   // e.g. LDA #<symbol, PEA addr
                                typeStr = "ref ";
                                break;
                            case OpDef.MemoryEffect.Unknown:
                            default:
                                Debug.Assert(false);
                                typeStr = "??! ";
                                break;
                        }
                        if (xr.IsIndexedAccess) {
                            idxStr = "idx ";
                        }
                        break;
                    default:
                        Debug.Assert(false);
                        typeStr = "??? ";
                        break;
                }

                MainWindow.ReferencesListItem rli = new MainWindow.ReferencesListItem(xr.Offset,
                    formatter.FormatOffset24(xr.Offset),
                    formatter.FormatAddress(mProject.GetAnattrib(xr.Offset).Address, showBank),
                    (xr.IsByName ? "Sym " : "Oth ") + typeStr + idxStr +
                        formatter.FormatAdjustment(-xr.Adjustment));

                mMainWin.ReferencesList.Add(rli);
            }
        }

        public bool CanUndo() {
            return (mProject != null && mProject.CanUndo);
        }

        /// <summary>
        /// Handles Edit - Undo.
        /// </summary>
        public void UndoChanges() {
            if (!mProject.CanUndo) {
                Debug.Assert(false, "Nothing to undo");
                return;
            }
            ChangeSet cs = mProject.PopUndoSet();
            ApplyChanges(cs, true);
            UpdateTitle();

            // If the debug dialog is visible, update it.
            if (mShowUndoRedoHistoryDialog != null) {
                mShowUndoRedoHistoryDialog.DisplayText = mProject.DebugGetUndoRedoHistory();
            }
        }

        public bool CanRedo() {
            return (mProject != null && mProject.CanRedo);
        }

        /// <summary>
        /// Handles Edit - Redo.
        /// </summary>
        public void RedoChanges() {
            if (!mProject.CanRedo) {
                Debug.Assert(false, "Nothing to redo");
                return;
            }
            ChangeSet cs = mProject.PopRedoSet();
            ApplyChanges(cs, false);
            UpdateTitle();

            // If the debug dialog is visible, update it.
            if (mShowUndoRedoHistoryDialog != null) {
                mShowUndoRedoHistoryDialog.DisplayText = mProject.DebugGetUndoRedoHistory();
            }
        }

        #endregion References panel

        #region Notes panel

        private void PopulateNotesList() {
            mMainWin.NotesList.Clear();
            foreach (KeyValuePair<int, MultiLineComment> kvp in mProject.Notes) {
                int offset = kvp.Key;
                MultiLineComment mlc = kvp.Value;

                // Replace line break with bullet.  If there's a single CRLF at the end, strip it.
                string nocrlfStr;
                if (mlc.Text.EndsWith("\r\n")) {
                    nocrlfStr =
                        mlc.Text.Substring(0, mlc.Text.Length - 2).Replace("\r\n", " \u2022 ");
                } else {
                    nocrlfStr = mlc.Text.Replace("\r\n", " \u2022 ");
                }

                MainWindow.NotesListItem nli = new MainWindow.NotesListItem(offset,
                    mFormatter.FormatOffset24(offset),
                    nocrlfStr,
                    mlc.BackgroundColor);
                mMainWin.NotesList.Add(nli);
            }
        }

        #endregion Notes panel

        #region Symbols panel

        /// <summary>
        /// Populates the ItemsSource for the Symbols window.  Each entry in the project
        /// symbol table is added.
        /// </summary>
        private void PopulateSymbolsList() {
            mMainWin.SymbolsList.Clear();
            foreach (Symbol sym in mProject.SymbolTable) {
                string valueStr = mFormatter.FormatHexValue(sym.Value, 0);
                string sourceTypeStr = sym.SourceTypeString;
                if (sym is DefSymbol) {
                    DefSymbol defSym = (DefSymbol)sym;
                    if (defSym.MultiMask != null) {
                        valueStr += " & " +
                            mFormatter.FormatHexValue(defSym.MultiMask.AddressMask, 4);
                    }
                    if (defSym.Direction == DefSymbol.DirectionFlags.Read) {
                        sourceTypeStr += '<';
                    } else if (defSym.Direction == DefSymbol.DirectionFlags.Write) {
                        sourceTypeStr += '>';
                    }
                }

                MainWindow.SymbolsListItem sli = new MainWindow.SymbolsListItem(sym,
                    sourceTypeStr, valueStr, sym.GenerateDisplayLabel(mFormatter));
                mMainWin.SymbolsList.Add(sli);
            }
        }

        #endregion Symbols panel

        #region Info panel

        private void UpdateInfoPanel() {
            mMainWin.ClearInfoPanel();
            if (mMainWin.CodeListView_GetSelectionCount() != 1) {
                // Nothing selected, or multiple lines selected.
                return;
            }
            int lineIndex = mMainWin.CodeListView_GetFirstSelectedIndex();
            LineListGen.Line line = CodeLineList[lineIndex];

            // TODO(someday): this should be made easier to localize
            string lineTypeStr = null;
            bool isSimple = true;
            DefSymbol defSym = null;
            switch (line.LineType) {
                case LineListGen.Line.Type.LongComment:
                    lineTypeStr = "comment";
                    break;
                case LineListGen.Line.Type.Note:
                    lineTypeStr = "note";
                    break;
                case LineListGen.Line.Type.Blank:
                    lineTypeStr = "blank line";
                    break;
                case LineListGen.Line.Type.OrgDirective:
                    lineTypeStr = "address directive";
                    break;
                case LineListGen.Line.Type.RegWidthDirective:
                    lineTypeStr = "register width directive";
                    break;

                case LineListGen.Line.Type.LocalVariableTable:
                    isSimple = false;
                    lineTypeStr = "variable table";
                    break;
                case LineListGen.Line.Type.Code:
                    isSimple = false;
                    lineTypeStr = "code";
                    break;
                case LineListGen.Line.Type.Data:
                    isSimple = false;
                    if (mProject.GetAnattrib(line.FileOffset).IsInlineData) {
                        lineTypeStr = "inline data";
                    } else {
                        lineTypeStr = "data";
                    }
                    break;
                case LineListGen.Line.Type.EquDirective:
                    isSimple = false;
                    int defSymIndex = LineListGen.DefSymIndexFromOffset(line.FileOffset);
                    defSym = mProject.ActiveDefSymbolList[defSymIndex];
                    if (defSym.SymbolSource == Symbol.Source.Project) {
                        lineTypeStr = "project symbol equate";
                    } else if (defSym.SymbolSource == Symbol.Source.Platform) {
                        lineTypeStr = "platform symbol equate";
                    } else {
                        lineTypeStr = "???";
                    }
                    break;
                case LineListGen.Line.Type.VisualizationSet:
                    lineTypeStr = "visualization set";
                    break;
                default:
                    lineTypeStr = "???";
                    break;
            }

            if (line.IsCodeOrData) {
                // Show number of bytes of code/data.
                if (line.OffsetSpan == 1) {
                    mMainWin.InfoLineDescrText =
                        string.Format(Res.Strings.INFO_LINE_SUM_SINGULAR_FMT,
                            lineIndex, line.OffsetSpan, lineTypeStr);
                } else {
                    mMainWin.InfoLineDescrText =
                        string.Format(Res.Strings.INFO_LINE_SUM_PLURAL_FMT,
                            lineIndex, line.OffsetSpan, lineTypeStr);
                }
            } else {
                mMainWin.InfoLineDescrText = string.Format(Res.Strings.INFO_LINE_SUM_NON_FMT,
                    lineIndex, lineTypeStr);
            }

#if DEBUG
            mMainWin.InfoOffsetText = ("[offset=+" + line.FileOffset.ToString("x6") + "]");
#endif
            if (isSimple) {
                return;
            }

            if (line.LineType == LineListGen.Line.Type.LocalVariableTable) {
                string str = string.Empty;
                if (mProject.LvTables.TryGetValue(line.FileOffset,
                        out LocalVariableTable lvt)) {
                    if (lvt.Count == 1) {
                        str = "1 entry";
                    } else {
                        str = lvt.Count + " entries";
                    }
                    if (lvt.ClearPrevious) {
                        str += "; clear previous";
                    }
                }
                mMainWin.InfoPanelDetail1 = str;
                return;
            }

            if (line.LineType == LineListGen.Line.Type.EquDirective) {
                StringBuilder esb = new StringBuilder();
                //esb.Append("\u25b6 ");
                esb.Append("\u2022 ");
                if (defSym.IsConstant) {
                    esb.Append("Constant");
                } else {
                    esb.Append("External address");
                    if (defSym.HasWidth) {
                        esb.Append(", width=");
                        esb.Append(defSym.DataDescriptor.Length);
                    }
                }
                if (defSym.Direction != DefSymbol.DirectionFlags.ReadWrite) {
                    esb.Append("\r\nI/O direction: ");
                    esb.Append(defSym.Direction);
                }
                if (defSym.MultiMask != null) {
                    esb.Append("\r\nMulti-mask:");
                    int i = 23;
                    if ((defSym.MultiMask.AddressMask | defSym.MultiMask.CompareMask |
                            defSym.MultiMask.CompareValue) < 0x10000) {
                        i = 15;
                    }
                    for ( ; i >= 0; i--) {
                        if ((i & 3) == 3) {
                            esb.Append(' ');
                        }
                        int bit = 1 << i;
                        if ((defSym.MultiMask.AddressMask & bit) != 0) {
                            esb.Append('x');
                        } else if ((defSym.MultiMask.CompareMask & bit) != 0) {
                            if ((defSym.MultiMask.CompareValue & bit) != 0) {
                                esb.Append('1');
                            } else {
                                esb.Append('0');
                            }
                        } else {
                            esb.Append('?');
                        }
                    }
                }
                if (defSym.SymbolSource == Symbol.Source.Platform) {
                    esb.Append("\r\n\r\nSource file # ");
                    esb.Append(defSym.LoadOrdinal);
                    esb.Append(": ");
                    esb.Append(defSym.FileIdentifier);

                    if (!string.IsNullOrEmpty(defSym.Tag)) {
                        esb.Append(", tag=");
                        esb.Append(defSym.Tag);
                    }
                }
                mMainWin.InfoPanelDetail1 = esb.ToString();
                return;
            }


            //
            // Handle code/data items.  In particular, the format descriptor.
            //
            Debug.Assert(line.IsCodeOrData);
            bool isCode = (line.LineType == LineListGen.Line.Type.Code);

            StringBuilder sb = new StringBuilder(250);
            Anattrib attr = mProject.GetAnattrib(line.FileOffset);

            if (attr.Symbol != null) {
                string descr;
                switch (attr.Symbol.SymbolType) {
                    case Symbol.Type.NonUniqueLocalAddr:
                        descr = "non-unique local";
                        break;
                    case Symbol.Type.LocalOrGlobalAddr:
                        descr = "unique local";
                        break;
                    case Symbol.Type.GlobalAddr:
                        descr = "unique global";
                        break;
                    case Symbol.Type.GlobalAddrExport:
                        descr = "global + marked for export";
                        break;
                    default:
                        descr = "???";
                        break;
                }
                if (attr.Symbol.SymbolSource == Symbol.Source.Auto) {
                    descr += ", auto-generated";
                } else if (attr.Symbol.LabelAnno == Symbol.LabelAnnotation.Generated) {
                    descr += " [gen]";
                }
                mMainWin.InfoLabelDescrText =
                    string.Format(Res.Strings.INFO_LABEL_DESCR_FMT, descr);
            }

            if (!mProject.OperandFormats.TryGetValue(line.FileOffset, out FormatDescriptor dfd)) {
                // No user-specified format, but there may be a generated format.
                mMainWin.InfoFormatBoxBrush = Brushes.Blue;
                if (attr.DataDescriptor != null) {
                    mMainWin.InfoFormatShowSolid = true;
                    sb.Append(Res.Strings.INFO_AUTO_FORMAT);
                    sb.Append(' ');
                    sb.Append(attr.DataDescriptor.ToUiString(!isCode));
                } else {
                    mMainWin.InfoFormatShowDashes = true;
                    sb.AppendFormat(Res.Strings.INFO_DEFAULT_FORMAT);
                }
            } else {
                // User-specified operand format.
                mMainWin.InfoFormatBoxBrush = Brushes.Green;
                mMainWin.InfoFormatShowSolid = true;
                sb.Append(Res.Strings.INFO_CUSTOM_FORMAT);
                sb.Append(' ');
                sb.Append(dfd.ToUiString(!isCode));
            }
            mMainWin.InfoFormatText = sb.ToString();

            sb.Clear();

            // Debug only
            //sb.Append("DEBUG: opAddr=" + attr.OperandAddress.ToString("x4") +
            //    " opOff=" + attr.OperandOffset.ToString("x4") + "\r\n");

            if (attr.IsHinted) {
                sb.Append("\u2022 Hints: ");
                for (int i = 0; i < line.OffsetSpan; i++) {
                    switch (mProject.TypeHints[line.FileOffset + i]) {
                        case CodeAnalysis.TypeHint.Code:
                            sb.Append("C");
                            break;
                        case CodeAnalysis.TypeHint.Data:
                            sb.Append("D");
                            break;
                        case CodeAnalysis.TypeHint.InlineData:
                            sb.Append("I");
                            break;
                        default:
                            break;
                    }
                    if (i > 8) {
                        sb.Append("...");
                        break;
                    }
                }
                sb.Append("\r\n");
            }

            if (attr.IsInstruction) {
                sb.Append("\r\n");

                Asm65.OpDef op = mProject.CpuDef.GetOpDef(mProject.FileData[line.FileOffset]);

                string shortDesc = mOpDesc.GetShortDescription(op.Mnemonic);
                if (!string.IsNullOrEmpty(shortDesc)) {
                    if (op.IsUndocumented) {
                        sb.Append("\u25b6[*] ");
                    } else {
                        sb.Append("\u25b6 ");
                    }
                    sb.Append(shortDesc);
                    string addrStr = mOpDesc.GetAddressModeDescription(op.AddrMode);
                    if (!string.IsNullOrEmpty(addrStr)) {
                        sb.Append(", ");
                        sb.Append(addrStr);
                    }
                    sb.Append("\r\n");
                }

                sb.Append("\u2022Cycles: ");
                int cycles = op.Cycles;
                sb.Append(cycles.ToString());

                OpDef.CycleMod allMods = op.CycleMods;
                OpDef.CycleMod nowMods =
                    mProject.CpuDef.GetOpCycleMod(mProject.FileData[line.FileOffset]);
                if (allMods != 0) {
                    StringBuilder nowSb = new StringBuilder();
                    StringBuilder otherSb = new StringBuilder();
                    int workBits = (int)allMods;
                    while (workBits != 0) {
                        // Isolate rightmost bit.
                        int firstBit = (~workBits + 1) & workBits;

                        string desc = mOpDesc.GetCycleModDescription((OpDef.CycleMod)firstBit);
                        if (((int)nowMods & firstBit) != 0) {
                            if (nowSb.Length != 0) {
                                nowSb.Append(", ");
                            }
                            nowSb.Append(desc);
                        } else {
                            if (otherSb.Length != 0) {
                                otherSb.Append(", ");
                            }
                            otherSb.Append(desc);
                        }
                        // Remove from set.
                        workBits &= ~firstBit;
                    }
                    if (nowSb.Length != 0) {
                        sb.Append(" (");
                        sb.Append(nowSb);
                        sb.Append(")");
                    }
                    if (otherSb.Length != 0) {
                        sb.Append(" [");
                        sb.Append(otherSb);
                        sb.Append("]");
                    }
                }
                sb.Append("\r\n");

                const string FLAGS = "NVMXDIZC";
                sb.Append("\u2022Flags affected: ");
                Asm65.StatusFlags affectedFlags = op.FlagsAffected;
                for (int i = 0; i < 8; i++) {
                    if (affectedFlags.GetBit((StatusFlags.FlagBits)(7 - i)) >= 0) {
                        sb.Append(' ');
                        sb.Append(FLAGS[i]);
                    } else {
                        sb.Append(" -");
                    }
                }
                sb.Append("\r\n");

                string longDesc = mOpDesc.GetLongDescription(op.Mnemonic);
                if (!string.IsNullOrEmpty(longDesc)) {
                    sb.Append("\r\n");
                    sb.Append(longDesc);
                }
            }

            // Publish
            mMainWin.InfoPanelDetail1 = sb.ToString();
        }

        #endregion Info panel

        #region Tools

        public void ToggleAsciiChart() {
            if (mAsciiChartDialog == null) {
                // Create without owner so it doesn't have to be in front of main window.
                mAsciiChartDialog = new Tools.WpfGui.AsciiChart(null);
                mAsciiChartDialog.Closing += (sender, e) => {
                    Debug.WriteLine("ASCII chart closed");
                    mAsciiChartDialog = null;
                };
                mAsciiChartDialog.Show();
            } else {
                mAsciiChartDialog.Close();
            }
        }

        public void ToggleInstructionChart() {
            if (mInstructionChartDialog == null) {
                // Create without owner so it doesn't have to be in front of main window.
                mInstructionChartDialog = new Tools.WpfGui.InstructionChart(null, mFormatter);
                mInstructionChartDialog.Closing += (sender, e) => {
                    Debug.WriteLine("Instruction chart closed");
                    mInstructionChartDialog = null;
                };
                mInstructionChartDialog.Show();
            } else {
                mInstructionChartDialog.Close();
            }
        }

        public void ShowFileHexDump() {
            if (!OpenAnyFile(out string pathName)) {
                return;
            }
            FileInfo fi = new FileInfo(pathName);
            if (fi.Length > Tools.WpfGui.HexDumpViewer.MAX_LENGTH) {
                string msg = string.Format(Res.Strings.OPEN_DATA_TOO_LARGE_FMT,
                    fi.Length / 1024, Tools.WpfGui.HexDumpViewer.MAX_LENGTH / 1024);
                MessageBox.Show(msg, Res.Strings.OPEN_DATA_FAIL_CAPTION,
                    MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }
            byte[] data;
            try {
                data = File.ReadAllBytes(pathName);
            } catch (Exception ex) {
                // not expecting this to happen
                MessageBox.Show(ex.Message);
                return;
            }

            // Create the dialog without an owner, and add it to the "unowned" list.
            Tools.WpfGui.HexDumpViewer dlg = new Tools.WpfGui.HexDumpViewer(null,
                data, mFormatter);
            dlg.SetFileName(Path.GetFileName(pathName));
            dlg.Closing += (sender, e) => {
                Debug.WriteLine("Window " + dlg + " closed, removing from unowned list");
                mUnownedWindows.Remove(dlg);
            };
            mUnownedWindows.Add(dlg);
            dlg.Show();
        }

        public void ConcatenateFiles() {
            Tools.WpfGui.FileConcatenator concat =
                new Tools.WpfGui.FileConcatenator(this.mMainWin);
            concat.ShowDialog();
        }

        public void SliceFiles() {
            if (!OpenAnyFile(out string pathName)) {
                return;
            }

            Tools.WpfGui.FileSlicer slicer = new Tools.WpfGui.FileSlicer(this.mMainWin, pathName,
                mFormatter);
            slicer.ShowDialog();
        }

        public void ConvertOmf() {
            if (!OpenAnyFile(out string pathName)) {
                return;
            }

            // Load the file here, so basic problems like empty / oversized files can be
            // reported immediately.

            byte[] fileData = null;
            using (FileStream fs = File.Open(pathName, FileMode.Open, FileAccess.Read)) {
                string errMsg = null;

                if (fs.Length == 0) {
                    errMsg = Res.Strings.OPEN_DATA_EMPTY;
                } else if (fs.Length < Tools.Omf.OmfFile.MIN_FILE_SIZE) {
                    errMsg = string.Format(Res.Strings.OPEN_DATA_TOO_SMALL_FMT, fs.Length);
                } else if (fs.Length > Tools.Omf.OmfFile.MAX_FILE_SIZE) {
                    errMsg = string.Format(Res.Strings.OPEN_DATA_TOO_LARGE_FMT,
                        fs.Length / 1024, Tools.Omf.OmfFile.MAX_FILE_SIZE / 1024);
                }
                if (errMsg == null) {
                    fileData = new byte[fs.Length];
                    int actual = fs.Read(fileData, 0, (int)fs.Length);
                    if (actual != fs.Length) {
                        errMsg = Res.Strings.OPEN_DATA_PARTIAL_READ;
                    }
                }

                if (errMsg != null) {
                    MessageBox.Show(errMsg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
                        MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
            }

            Tools.Omf.WpfGui.OmfViewer ov =
                new Tools.Omf.WpfGui.OmfViewer(this.mMainWin, pathName, fileData);
            ov.ShowDialog();
        }

        private bool OpenAnyFile(out string pathName) {
            OpenFileDialog fileDlg = new OpenFileDialog() {
                Filter = Res.Strings.FILE_FILTER_ALL,
                FilterIndex = 1
            };
            if (fileDlg.ShowDialog() != true) {
                pathName = null;
                return false;
            }
            pathName = Path.GetFullPath(fileDlg.FileName);
            return true;
        }

        #endregion Tools

        #region Debug features

        public void Debug_ExtensionScriptInfo() {
            string info = mProject.DebugGetLoadedScriptInfo();

            Tools.WpfGui.ShowText dlg = new Tools.WpfGui.ShowText(mMainWin, info);
            dlg.Title = "Loaded Extension Script Info";
            dlg.ShowDialog();
        }

        public void Debug_ShowAnalysisTimers() {
            if (mShowAnalysisTimersDialog == null) {
                Tools.WpfGui.ShowText dlg = new Tools.WpfGui.ShowText(null, "(no data yet)");
                dlg.Title = "Analysis Timers";
                dlg.Closing += (sender, e) => {
                    Debug.WriteLine("Analysis timers dialog closed");
                    mShowAnalysisTimersDialog = null;
                };
                dlg.Show();
                mShowAnalysisTimersDialog = dlg;
            } else {
                // Ask the dialog to close.  Do the cleanup in the event.
                mShowAnalysisTimersDialog.Close();
            }
        }

        public void Debug_ShowAnalyzerOutput() {
            if (mShowAnalyzerOutputDialog == null) {
                Tools.WpfGui.ShowText dlg = new Tools.WpfGui.ShowText(null, "(no data yet)");
                dlg.Title = "Analyzer Output";
                dlg.Closing += (sender, e) => {
                    Debug.WriteLine("Analyzer output dialog closed");
                    mShowAnalyzerOutputDialog = null;
                };
                dlg.Show();
                mShowAnalyzerOutputDialog = dlg;
            } else {
                // Ask the dialog to close.  Do the cleanup in the event.
                mShowAnalyzerOutputDialog.Close();
            }
        }

        public void Debug_ShowUndoRedoHistory() {
            if (mShowUndoRedoHistoryDialog == null) {
                Tools.WpfGui.ShowText dlg = new Tools.WpfGui.ShowText(null,
                    mProject.DebugGetUndoRedoHistory());
                dlg.Title = "Undo/Redo History";
                dlg.Closing += (sender, e) => {
                    Debug.WriteLine("Undo/redo history dialog closed");
                    mShowUndoRedoHistoryDialog = null;
                };
                dlg.Show();
                mShowUndoRedoHistoryDialog = dlg;
            } else {
                // Ask the dialog to close.  Do the cleanup in the event.
                mShowUndoRedoHistoryDialog.Close();
            }
        }

        public void Debug_RunSourceGenerationTests() {
            Tests.WpfGui.GenTestRunner dlg = new Tests.WpfGui.GenTestRunner(mMainWin);
            dlg.ShowDialog();
        }

        public void Debug_Refresh() {
            Debug.WriteLine("Reanalyzing...");
            // Call through ApplyChanges so we update the timer task output.
            UndoableChange uc =
                UndoableChange.CreateDummyChange(UndoableChange.ReanalysisScope.CodeAndData);
            ApplyChanges(new ChangeSet(uc), false);
            UpdateTitle();  // in case something changed
        }

        public void Debug_ToggleCommentRulers() {
            MultiLineComment.DebugShowRuler = !MultiLineComment.DebugShowRuler;
            // Don't need to repeat the analysis, but we do want to save/restore the
            // selection and top position when the comment fields change size.
            UndoableChange uc =
                UndoableChange.CreateDummyChange(UndoableChange.ReanalysisScope.DataOnly);
            ApplyChanges(new ChangeSet(uc), false);
        }

        public void Debug_ToggleKeepAliveHack() {
            ScriptManager.UseKeepAliveHack = !ScriptManager.UseKeepAliveHack;
        }

        public void Debug_ApplesoftToHtml() {
            if (!OpenAnyFile(out string basPathName)) {
                return;
            }

            byte[] data;
            try {
                data = File.ReadAllBytes(basPathName);
            } catch (IOException ex) {
                // not expecting this to happen
                MessageBox.Show(ex.Message);
                return;
            }

            Tools.ApplesoftToHtml conv = new Tools.ApplesoftToHtml();
            string html = conv.Convert(data);

            Tools.WpfGui.ShowText showTextDlg = new Tools.WpfGui.ShowText(mMainWin, html);
            showTextDlg.Title = "Applesoft to HTML";
            showTextDlg.ShowDialog();
        }

        public void Debug_ApplyEditCommands() {
            OpenFileDialog fileDlg = new OpenFileDialog() {
                Filter = Res.Strings.FILE_FILTER_SGEC + "|" + Res.Strings.FILE_FILTER_ALL,
                FilterIndex = 1
            };
            if (fileDlg.ShowDialog() != true) {
                return;
            }

            string sgecPathName = Path.GetFullPath(fileDlg.FileName);
            string[] lines;
            try {
                lines = File.ReadAllLines(sgecPathName);
            } catch (IOException ex) {
                // not expecting this to happen
                MessageBox.Show(ex.Message);
                return;
            }

            ChangeSet cs = new ChangeSet(1);

            List<int> changed = new List<int>(lines.Length);

            string setComment = "set-comment +";
            foreach (string line in lines) {
                if (!line.StartsWith(setComment)) {
                    Debug.WriteLine("Ignoring " + line);
                    continue;
                }

                int offset;
                try {
                    offset = Convert.ToInt32(line.Substring(setComment.Length, 6), 16);
                } catch (Exception ex) {
                    Debug.WriteLine("Failed on " + line);
                    MessageBox.Show(ex.Message);
                    return;
                }

                if (changed.Contains(offset)) {
                    Debug.WriteLine("Skipping repeated entry +" + offset.ToString("X6"));
                    continue;
                }

                string oldComment = mProject.Comments[offset];
                string newComment = line.Substring(setComment.Length + 7);
                if (!string.IsNullOrEmpty(oldComment)) {
                    Debug.WriteLine("Replacing comment +" + offset.ToString("x6") +
                        " '" + oldComment + "'");
                }

                UndoableChange uc = UndoableChange.CreateCommentChange(offset,
                    oldComment, newComment);
                cs.Add(uc);
                changed.Add(offset);
            }

            ApplyUndoableChanges(cs);
        }

        // Disable "analyze uncategorized data" for best results.
        public void Debug_ApplyPlatformSymbols() {
            ChangeSet cs = new ChangeSet(1);

            foreach (Symbol sym in mProject.SymbolTable) {
                if (sym.SymbolSource != Symbol.Source.Platform) {
                    continue;
                }
                DefSymbol defSym = (DefSymbol)sym;
                if (defSym.MultiMask != null) {
                    // These would require additional work... probably.
                    continue;
                }

                int offset = mProject.AddrMap.AddressToOffset(0, sym.Value);
                if (offset < 0) {
                    continue;
                }

                // Make sure this is the start of an instruction or data item.  (If you
                // haven't finished hinting code, it's best to disable the string/fill finder.)
                Anattrib attr = mProject.GetAnattrib(offset);
                if (!attr.IsStart) {
                    Debug.WriteLine("Found match at non-start +" + offset.ToString("x6") +
                        ": " + defSym);
                    continue;
                }

                // Check for user label.  Okay to overwrite auto label.
                if (mProject.UserLabels.ContainsKey(offset)) {
                    Debug.WriteLine("User label already exists at +" + offset.ToString("x6"));
                    continue;
                }

                // Create a new user label symbol.  We should not be creating a duplicate name,
                // because user labels have priority over platform symbols when populating
                // the symbol table.
                Symbol newSym = new Symbol(sym.Label, sym.Value, Symbol.Source.User,
                    Symbol.Type.GlobalAddr, Symbol.LabelAnnotation.None);
                UndoableChange uc = UndoableChange.CreateLabelChange(offset, null, newSym);
                cs.Add(uc);
            }

            ApplyUndoableChanges(cs);
        }

        #endregion Debug features
    }
}
