// // FileDialog.cs: File system dialogs for open and save // // TODO: // * Add directory selector // * Implement subclasses // * Figure out why message text does not show // * Remove the extra space when message does not show // * Use a line separator to show the file listing, so we can use same colors as the rest // * DirListView: Add mouse support using System; using System.Collections.Generic; using NStack; using System.IO; using System.Linq; namespace Terminal.Gui { internal class DirListView : View { int top, selected; DirectoryInfo dirInfo; List<(string,bool,bool)> infos; internal bool canChooseFiles = true; internal bool canChooseDirectories = false; internal bool allowsMultipleSelection = false; FileDialog host; public DirListView (FileDialog host) { infos = new List<(string,bool,bool)> (); CanFocus = true; this.host = host; } bool IsAllowed (FileSystemInfo fsi) { if (fsi.Attributes.HasFlag (FileAttributes.Directory)) return true; if (allowedFileTypes == null) return true; foreach (var ft in allowedFileTypes) if (fsi.Name.EndsWith (ft)) return true; return false; } internal void Reload () { dirInfo = new DirectoryInfo (directory.ToString ()); infos = (from x in dirInfo.GetFileSystemInfos () where IsAllowed (x) orderby (!x.Attributes.HasFlag (FileAttributes.Directory)) + x.Name select (x.Name, x.Attributes.HasFlag (FileAttributes.Directory), false)).ToList (); infos.Insert (0, ("..", true, false)); top = 0; selected = 0; SetNeedsDisplay (); } ustring directory; public ustring Directory { get => directory; set { if (directory == value) return; directory = value; Reload (); } } public override void PositionCursor () { Move (0, selected - top); } int lastSelected; bool shiftOnWheel; public override bool MouseEvent (MouseEvent me) { if ((me.Flags & (MouseFlags.Button1Clicked | MouseFlags.Button1DoubleClicked | MouseFlags.WheeledUp | MouseFlags.WheeledDown)) == 0) return false; if (!HasFocus) SuperView.SetFocus (this); if (infos == null) return false; if (me.Y + top >= infos.Count) return true; int lastSelectedCopy = shiftOnWheel ? lastSelected : selected; switch (me.Flags) { case MouseFlags.Button1Clicked: SetSelected (me); SelectionChanged (); SetNeedsDisplay (); break; case MouseFlags.Button1DoubleClicked: SetSelected (me); if (ExecuteSelection ()) { host.canceled = false; Application.RequestStop (); } return true; case MouseFlags.Button1Clicked | MouseFlags.ButtonShift: SetSelected (me); if (shiftOnWheel) lastSelected = lastSelectedCopy; shiftOnWheel = false; PerformMultipleSelection (lastSelected); return true; case MouseFlags.Button1Clicked | MouseFlags.ButtonCtrl: SetSelected (me); PerformMultipleSelection (); return true; case MouseFlags.WheeledUp: SetSelected (me); selected = lastSelected; MoveUp (); return true; case MouseFlags.WheeledDown: SetSelected (me); selected = lastSelected; MoveDown (); return true; case MouseFlags.WheeledUp | MouseFlags.ButtonShift: SetSelected (me); selected = lastSelected; lastSelected = lastSelectedCopy; shiftOnWheel = true; MoveUp (); return true; case MouseFlags.WheeledDown | MouseFlags.ButtonShift: SetSelected (me); selected = lastSelected; lastSelected = lastSelectedCopy; shiftOnWheel = true; MoveDown (); return true; } return true; } void SetSelected (MouseEvent me) { lastSelected = selected; selected = top + me.Y; } void DrawString (int line, string str) { var f = Frame; var width = f.Width; var ustr = ustring.Make (str); Move (allowsMultipleSelection ? 3 : 2, line); int byteLen = ustr.Length; int used = 0; for (int i = 0; i < byteLen;) { (var rune, var size) = Utf8.DecodeRune (ustr, i, i - byteLen); var count = Rune.ColumnWidth (rune); if (used + count >= width) break; Driver.AddRune (rune); used += count; i += size; } for (; used < width; used++) { Driver.AddRune (' '); } } public override void Redraw (Rect region) { var current = ColorScheme.Focus; Driver.SetAttribute (current); Move (0, 0); var f = Frame; var item = top; bool focused = HasFocus; var width = region.Width; for (int row = 0; row < f.Height; row++, item++) { bool isSelected = item == selected; Move (0, row); var newcolor = focused ? (isSelected ? ColorScheme.HotNormal : ColorScheme.Focus) : ColorScheme.Focus; if (newcolor != current) { Driver.SetAttribute (newcolor); current = newcolor; } if (item >= infos.Count) { for (int c = 0; c < f.Width; c++) Driver.AddRune (' '); continue; } var fi = infos [item]; Driver.AddRune (isSelected ? '>' : ' '); if (allowsMultipleSelection) Driver.AddRune (fi.Item3 ? '*' : ' '); if (fi.Item2) Driver.AddRune ('/'); else Driver.AddRune (' '); DrawString (row, fi.Item1); } } public Action<(string, bool)> SelectedChanged { get; set; } public Action DirectoryChanged { get; set; } public Action FileChanged { get; set; } void SelectionChanged () { if (FilePaths.Count > 0) FileChanged?.Invoke (string.Join (", ", GetFilesName (FilePaths))); else FileChanged?.Invoke (infos [selected].Item2 ? "" : Path.GetFileName (infos [selected].Item1)); if (SelectedChanged != null) { var sel = infos [selected]; SelectedChanged ((sel.Item1, sel.Item2)); } } List GetFilesName (IReadOnlyList files) { List filesName = new List (); foreach (var file in files) { filesName.Add (Path.GetFileName (file)); } return filesName; } public override bool ProcessKey (KeyEvent keyEvent) { switch (keyEvent.Key) { case Key.CursorUp: case Key.ControlP: MoveUp (); return true; case Key.CursorDown: case Key.ControlN: MoveDown (); return true; case Key.ControlV: case Key.PageDown: var n = (selected + Frame.Height); if (n > infos.Count) n = infos.Count - 1; if (n != selected) { selected = n; if (infos.Count >= Frame.Height) top = selected; else top = 0; SelectionChanged (); SetNeedsDisplay (); } return true; case Key.Enter: if (ExecuteSelection ()) return false; else return true; case Key.PageUp: n = (selected - Frame.Height); if (n < 0) n = 0; if (n != selected) { selected = n; top = selected; SelectionChanged (); SetNeedsDisplay (); } return true; case Key.Space: case Key.ControlT: PerformMultipleSelection (); return true; case Key.Home: MoveFirst (); return true; case Key.End: MoveLast (); return true; } return base.ProcessKey (keyEvent); } void MoveLast () { selected = infos.Count - 1; top = infos.Count () - 1; SelectionChanged (); SetNeedsDisplay (); } void MoveFirst () { selected = 0; top = 0; SelectionChanged (); SetNeedsDisplay (); } void MoveDown () { if (selected + 1 < infos.Count) { selected++; if (selected >= top + Frame.Height) top++; SelectionChanged (); SetNeedsDisplay (); } } void MoveUp () { if (selected > 0) { selected--; if (selected < top) top = selected; SelectionChanged (); SetNeedsDisplay (); } } internal bool ExecuteSelection () { var isDir = infos [selected].Item2; if (isDir) { Directory = Path.GetFullPath (Path.Combine (Path.GetFullPath (Directory.ToString ()), infos [selected].Item1)); DirectoryChanged?.Invoke (Directory); } else { FileChanged?.Invoke (infos [selected].Item1); if (canChooseFiles) { // Ensures that at least one file is selected. if (FilePaths.Count == 0) PerformMultipleSelection (); // Let the OK handler take it over return true; } // No files allowed, do not let the default handler take it. } return false; } void PerformMultipleSelection (int? firstSelected = null) { if (allowsMultipleSelection) { int first = Math.Min (firstSelected ?? selected, selected); int last = Math.Max (selected, firstSelected ?? selected); for (int i = first; i <= last; i++) { if ((canChooseFiles && infos [i].Item2 == false) || (canChooseDirectories && infos [i].Item2 && infos [i].Item1 != "..")) { infos [i] = (infos [i].Item1, infos [i].Item2, !infos [i].Item3); } } SelectionChanged (); SetNeedsDisplay (); } } string [] allowedFileTypes; public string [] AllowedFileTypes { get => allowedFileTypes; set { allowedFileTypes = value; Reload (); } } public string MakePath (string relativePath) { return Path.GetFullPath (Path.Combine (Directory.ToString (), relativePath)); } public IReadOnlyList FilePaths { get { if (allowsMultipleSelection) { var res = new List (); foreach (var item in infos) if (item.Item3) res.Add (MakePath (item.Item1)); return res; } else { if (infos [selected].Item2) { if (canChooseDirectories) return new List () { MakePath (infos [selected].Item1) }; return Array.Empty (); } else { if (canChooseFiles) return new List () { MakePath (infos [selected].Item1) }; return Array.Empty (); } } } } } /// /// Base class for the OpenDialog and the SaveDialog /// public class FileDialog : Dialog { Button prompt, cancel; Label nameFieldLabel, message, dirLabel; TextField dirEntry, nameEntry; internal DirListView dirListView; /// /// Constructor for the OpenDialog and the SaveDialog. /// /// The title. /// The prompt. /// The name field label. /// The message. public FileDialog (ustring title, ustring prompt, ustring nameFieldLabel, ustring message) : base (title, Driver.Cols - 20, Driver.Rows - 5, null) { this.message = new Label (Rect.Empty, "MESSAGE" + message); var msgLines = Label.MeasureLines (message, Driver.Cols - 20); dirLabel = new Label ("Directory: ") { X = 1, Y = 1 + msgLines }; dirEntry = new TextField ("") { X = Pos.Right (dirLabel), Y = 1 + msgLines, Width = Dim.Fill () - 1 }; Add (dirLabel, dirEntry); this.nameFieldLabel = new Label ("Open: ") { X = 6, Y = 3 + msgLines, }; nameEntry = new TextField ("") { X = Pos.Left (dirEntry), Y = 3 + msgLines, Width = Dim.Fill () - 1 }; Add (this.nameFieldLabel, nameEntry); dirListView = new DirListView (this) { X = 1, Y = 3 + msgLines + 2, Width = Dim.Fill () - 3, Height = Dim.Fill () - 2, }; DirectoryPath = Path.GetFullPath (Environment.CurrentDirectory); Add (dirListView); dirListView.DirectoryChanged = (dir) => dirEntry.Text = dir; dirListView.FileChanged = (file) => nameEntry.Text = file; this.cancel = new Button ("Cancel"); this.cancel.Clicked += () => { canceled = true; Application.RequestStop (); }; AddButton (cancel); this.prompt = new Button (prompt) { IsDefault = true, }; this.prompt.Clicked += () => { dirListView.ExecuteSelection (); canceled = false; Application.RequestStop (); }; AddButton (this.prompt); // On success, we will set this to false. canceled = true; } internal bool canceled; /// public override void WillPresent () { base.WillPresent (); //SetFocus (nameEntry); } /// /// Gets or sets the prompt label for the button displayed to the user /// /// The prompt. public ustring Prompt { get => prompt.Text; set { prompt.Text = value; } } /// /// Gets or sets the name field label. /// /// The name field label. public ustring NameFieldLabel { get => nameFieldLabel.Text; set { nameFieldLabel.Text = value; } } /// /// Gets or sets the message displayed to the user, defaults to nothing /// /// The message. public ustring Message { get => message.Text; set { message.Text = value; } } /// /// Gets or sets a value indicating whether this can create directories. /// /// true if can create directories; otherwise, false. public bool CanCreateDirectories { get; set; } /// /// Gets or sets a value indicating whether this is extension hidden. /// /// true if is extension hidden; otherwise, false. public bool IsExtensionHidden { get; set; } /// /// Gets or sets the directory path for this panel /// /// The directory path. public ustring DirectoryPath { get => dirEntry.Text; set { dirEntry.Text = value; dirListView.Directory = value; } } /// /// The array of filename extensions allowed, or null if all file extensions are allowed. /// /// The allowed file types. public string [] AllowedFileTypes { get => dirListView.AllowedFileTypes; set => dirListView.AllowedFileTypes = value; } /// /// Gets or sets a value indicating whether this allows the file to be saved with a different extension /// /// true if allows other file types; otherwise, false. public bool AllowsOtherFileTypes { get; set; } /// /// The File path that is currently shown on the panel /// /// The absolute file path for the file path entered. public ustring FilePath { get => dirListView.MakePath(nameEntry.Text.ToString()); set { nameEntry.Text = Path.GetFileName(value.ToString()); } } /// /// Check if the dialog was or not canceled. /// public bool Canceled { get => canceled; } } /// /// The save dialog provides an interactive dialog box for users to pick a file to /// save. /// /// /// /// To use it, create an instance of the SaveDialog, and then /// call Application.Run on the resulting instance. This will run the dialog modally, /// and when this returns, the FileName property will contain the selected value or /// null if the user canceled. /// /// public class SaveDialog : FileDialog { /// /// Constructor of the save dialog. /// /// The title. /// The message. public SaveDialog (ustring title, ustring message) : base (title, prompt: "Save", nameFieldLabel: "Save as:", message: message) { } /// /// Gets the name of the file the user selected for saving, or null /// if the user canceled the dialog box. /// /// The name of the file. public ustring FileName { get { if (canceled) return null; return Path.GetFileName(FilePath.ToString()); } } } /// /// The Open Dialog provides an interactive dialog box for users to select files or directories. /// /// /// /// The open dialog can be used to select files for opening, it can be configured to allow /// multiple items to be selected (based on the AllowsMultipleSelection) variable and /// you can control whether this should allow files or directories to be selected. /// /// /// To use it, create an instance of the OpenDialog, configure its properties, and then /// call Application.Run on the resulting instance. This will run the dialog modally, /// and when this returns, the list of filds will be available on the FilePaths property. /// /// /// To select more than one file, users can use the spacebar, or control-t. /// /// public class OpenDialog : FileDialog { /// /// Constructor of the Open Dialog. /// /// /// public OpenDialog (ustring title, ustring message) : base (title, prompt: "Open", nameFieldLabel: "Open", message: message) { } /// /// Gets or sets a value indicating whether this can choose files. /// /// true if can choose files; otherwise, false. Defaults to true public bool CanChooseFiles { get => dirListView.canChooseFiles; set { dirListView.canChooseFiles = value; dirListView.Reload (); } } /// /// Gets or sets a value indicating whether this can choose directories. /// /// true if can choose directories; otherwise, false defaults to false. public bool CanChooseDirectories { get => dirListView.canChooseDirectories; set { dirListView.canChooseDirectories = value; dirListView.Reload (); } } /// /// Gets or sets a value indicating whether this allows multiple selection. /// /// true if allows multiple selection; otherwise, false, defaults to false. public bool AllowsMultipleSelection { get => dirListView.allowsMultipleSelection; set { dirListView.allowsMultipleSelection = value; dirListView.Reload (); } } /// /// Returns the selected files, or an empty list if nothing has been selected /// /// The file paths. public IReadOnlyList FilePaths { get => dirListView.FilePaths; } } }