// // StatusBar.cs // // Author: // Marius Ungureanu // // Copyright (c) 2015 Xamarin, Inc (http://www.xamarin.com) // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. using System; using System.Collections.Generic; using System.Linq; using AppKit; using Foundation; using CoreAnimation; using CoreGraphics; using MonoDevelop.Components; using MonoDevelop.Core; using MonoDevelop.Ide.Gui; using MonoDevelop.Components.MainToolbar; using MonoDevelop.Ide; using MonoDevelop.Ide.Gui.Components; using MonoDevelop.Ide.Tasks; namespace MonoDevelop.MacIntegration.MainToolbar { class StatusIcon : StatusBarIcon { StatusBar bar; CALayer layer; public StatusIcon (StatusBar bar, CALayer layer, NSTrackingArea trackingArea) { this.bar = bar; this.layer = layer; TrackingArea = trackingArea; } public void SetAlertMode (int seconds) { // Create fade-out fade-in animation. } public void Dispose () { layer.RemoveFromSuperLayer (); bar.RemoveStatusLayer (layer); } public string ToolTip { get; set; } internal NSTrackingArea TrackingArea { get; set; } Xwt.Drawing.Image image; public Xwt.Drawing.Image Image { get { return image; } set { image = value; layer.SetImage (value, bar.Window.BackingScaleFactor); } } internal void NotifyClicked (Xwt.PointerButton button) { if (Clicked != null) Clicked (this, new StatusBarIconClickedEventArgs { Button = button, }); } public event EventHandler Clicked; } [Register] class StatusBar : NSTextField, MonoDevelop.Ide.StatusBar { const string ProgressLayerId = "ProgressLayer"; const string ProgressLayerFadingId = "ProgressLayerFading"; const string StatusIconPrefixId = "StatusLayer"; const string BuildIconLayerId = "BuildIconLayer"; const string BuildTextLayerId = "BuildTextLayer"; const string SeparatorLayerId = "SeparatorLayer"; const string growthAnimationKey = "bounds"; StatusBarContextHandler ctxHandler; Stack progressMarks = new Stack (); bool currentTextIsMarkup; string text; NSColor textColor; NSImage image; IconId icon; AnimatedIcon iconAnimation; IDisposable xwtAnimation; NSAttributedString GetStatusString (string text, NSColor color) { return new NSAttributedString (text, new NSStringAttributes { ForegroundColor = color, ParagraphStyle = new NSMutableParagraphStyle { HeadIndent = imageView.Frame.Width, LineBreakMode = NSLineBreakMode.TruncatingMiddle, }, Font = NSFont.SystemFontOfSize (NSFont.SystemFontSize - 2), }); } readonly NSImageView imageView = new NSImageView { ImageFrameStyle = NSImageFrameStyle.None, Editable = false, }; readonly NSTextField textField = new NSTextField { AllowsEditingTextAttributes = true, Bordered = false, DrawsBackground = false, Bezeled = false, Editable = false, Selectable = false, }; TaskEventHandler updateHandler; public StatusBar () { AllowsEditingTextAttributes = Selectable = Editable = false; textField.Cell = new VerticallyCenteredTextFieldCell (yOffset: -0.5f); textField.Cell.StringValue = ""; textField.Cell.PlaceholderAttributedString = GetStatusString (BrandingService.ApplicationName, NSColor.DisabledControlText); imageView.Image = ImageService.GetIcon (Stock.StatusSteady).ToNSImage (); // Fixes a render glitch of a whiter bg than the others. if (MacSystemInformation.OsVersion >= MacSystemInformation.Yosemite) BezelStyle = NSTextFieldBezelStyle.Rounded; WantsLayer = true; Layer.CornerRadius = 4; ctxHandler = new StatusBarContextHandler (this); updateHandler = delegate { int ec=0, wc=0; foreach (var t in TaskService.Errors) { if (t.Severity == TaskSeverity.Error) ec++; else if (t.Severity == TaskSeverity.Warning) wc++; } DispatchService.GuiDispatch (delegate { if (ec > 0) { buildResultVisible = true; buildResultText.AttributedString = new NSAttributedString (ec.ToString (), foregroundColor: NSColor.Text, font: NSFont.SystemFontOfSize (NSFont.SmallSystemFontSize - 1)); buildResultText.ContentsScale = Window.BackingScaleFactor; buildResultIcon.SetImage (buildImageId = "md-status-error-count", Window.BackingScaleFactor); } else if (wc > 0) { buildResultVisible = true; buildResultText.AttributedString = new NSAttributedString (wc.ToString (), foregroundColor: NSColor.Text, font: NSFont.SystemFontOfSize (NSFont.SmallSystemFontSize - 1)); buildResultText.ContentsScale = Window.BackingScaleFactor; buildResultIcon.SetImage (buildImageId = "md-status-warning-count", Window.BackingScaleFactor); } else buildResultVisible = false; CATransaction.DisableActions = true; nfloat buildResultPosition = DrawBuildResults (); CATransaction.DisableActions = false; if (buildResultPosition == nfloat.PositiveInfinity) return; textField.SetFrameSize (new CGSize (buildResultPosition - 6 - textField.Frame.Left, Frame.Height)); }); }; updateHandler (null, null); TaskService.Errors.TasksAdded += updateHandler; TaskService.Errors.TasksRemoved += updateHandler; NSNotificationCenter.DefaultCenter.AddObserver (NSWindow.DidChangeBackingPropertiesNotification, delegate { if (Window == null) return; ReconstructString (); foreach (var layer in Layer.Sublayers) { if (layer.Name != null && layer.Name.StartsWith (StatusIconPrefixId, StringComparison.Ordinal)) layer.SetImage (layerToStatus [layer.Name].Image, Window.BackingScaleFactor); } if (buildResultVisible) { buildResultIcon.SetImage (buildImageId, Window.BackingScaleFactor); buildResultText.ContentsScale = Window.BackingScaleFactor; } }); AddSubview (imageView); AddSubview (textField); } public override void DrawRect (CGRect dirtyRect) { if (imageView.Frame.Location == CGPoint.Empty) imageView.Frame = new CGRect (6, 0, 16, Frame.Height); if (textField.Frame.Location == CGPoint.Empty) textField.Frame = new CGRect (imageView.Frame.Right, 0, Frame.Width - 16, Frame.Height); base.DrawRect (dirtyRect); } protected override void Dispose (bool disposing) { TaskService.Errors.TasksAdded -= updateHandler; TaskService.Errors.TasksRemoved -= updateHandler; base.Dispose (disposing); } void ReconstructString () { if (string.IsNullOrEmpty (text)) { textField.AttributedStringValue = new NSAttributedString (""); imageView.Image = ImageService.GetIcon (Stock.StatusSteady).ToNSImage (); } else { textField.AttributedStringValue = GetStatusString (text, textColor); imageView.Image = image; } } CALayer ProgressLayer { get { return Layer.Sublayers.FirstOrDefault (l => l.Name == ProgressLayerId); } } readonly Dictionary layerToStatus = new Dictionary (); internal void RemoveStatusLayer (CALayer statusLayer) { RemoveTrackingArea (layerToStatus [statusLayer.Name].TrackingArea); layerToStatus.Remove (statusLayer.Name); RepositionStatusLayers (); } nfloat LeftMostStatusItemX () { if (Layer.Sublayers == null) return Layer.Frame.Width; var left = Layer.Sublayers.Min (layer => { if (layer.Name == null) return nfloat.PositiveInfinity; if (layer.Name.StartsWith (StatusIconPrefixId, StringComparison.Ordinal)) return layer.Frame.Left; return nfloat.PositiveInfinity; }); return left == nfloat.PositiveInfinity ? Layer.Frame.Width : left; } nfloat DrawSeparatorIfNeeded (nfloat right) { CALayer layer = Layer.Sublayers.FirstOrDefault (l => l.Name == SeparatorLayerId); if (layerToStatus.Count == 0) { if (layer != null) layer.RemoveFromSuperLayer (); return right; } right -= 9; if (layer != null) { layer.Frame = new CGRect (right, 3, 1, 16); layer.SetNeedsDisplay (); } else { layer = CALayer.Create (); layer.Name = SeparatorLayerId; layer.Frame = new CGRect (right, 3, 1, 16); layer.BackgroundColor = NSColor.LightGray.CGColor; Layer.AddSublayer (layer); } return right - 3; } bool buildResultVisible; readonly CATextLayer buildResultText = new CATextLayer { Name = BuildTextLayerId, }; IconId buildImageId; readonly CALayer buildResultIcon = new CALayer { Name = BuildIconLayerId, }; nfloat DrawBuildResults () { if (!buildResultVisible) { if (Layer.Sublayers != null) { CALayer layer; layer = Layer.Sublayers.FirstOrDefault (l => l.Name != null && l.Name.StartsWith (BuildIconLayerId, StringComparison.Ordinal)); if (layer != null) layer.RemoveFromSuperLayer (); layer = Layer.Sublayers.FirstOrDefault (l => l.Name != null && l.Name.StartsWith (BuildTextLayerId, StringComparison.Ordinal)); if (layer != null) layer.RemoveFromSuperLayer (); layer = Layer.Sublayers.FirstOrDefault (l => l.Name != null && l.Name.StartsWith (SeparatorLayerId, StringComparison.Ordinal)); if (layer != null) layer.RemoveFromSuperLayer (); } return nfloat.PositiveInfinity; } nfloat right = DrawSeparatorIfNeeded (LeftMostStatusItemX ()); CGSize size = buildResultText.AttributedString.Size; right = right - 6 - size.Width; buildResultText.Frame = new CGRect (right, 5f, size.Width, size.Height); if (buildResultText.SuperLayer == null) Layer.AddSublayer (buildResultText); buildResultText.SetNeedsDisplay (); right -= buildResultIcon.Bounds.Width; buildResultIcon.Frame = new CGRect (right, 3, buildResultIcon.Bounds.Width, buildResultIcon.Bounds.Height); if (buildResultIcon.SuperLayer == null) Layer.AddSublayer (buildResultIcon); return right; } internal void RepositionStatusLayers () { nfloat right = Layer.Frame.Width; CATransaction.DisableActions = true; foreach (var item in Layer.Sublayers) { if (item.Name != null && item.Name.StartsWith (StatusIconPrefixId, StringComparison.Ordinal)) { var icon = layerToStatus [item.Name]; RemoveTrackingArea (icon.TrackingArea); right -= item.Bounds.Width + 6; item.Frame = new CGRect (right, 3, item.Bounds.Width, item.Bounds.Height); var area = new NSTrackingArea (item.Frame, NSTrackingAreaOptions.MouseEnteredAndExited | NSTrackingAreaOptions.ActiveInActiveApp, this, null); AddTrackingArea (area); icon.TrackingArea = area; } } nfloat buildResultPosition = DrawBuildResults (); CATransaction.DisableActions = false; if (buildResultPosition < right) { // We have a build result layer. textField.SetFrameSize (new CGSize (buildResultPosition - 6 - textField.Frame.Left, Frame.Height)); } else textField.SetFrameSize (new CGSize (right - 6 - textField.Frame.Left, Frame.Height)); } long statusCounter; public StatusBarIcon ShowStatusIcon (Xwt.Drawing.Image pixbuf) { nfloat right = layerToStatus.Count == 0 ? Layer.Frame.Width : Layer.Sublayers.Last (i => i.Name != null && i.Name.StartsWith (StatusIconPrefixId, StringComparison.Ordinal)).Frame.Left; right -= (nfloat)pixbuf.Width + 6; var layer = CALayer.Create (); layer.Name = StatusIconPrefixId + (++statusCounter); layer.Bounds = new CGRect (0, 0, (nfloat)pixbuf.Width, (nfloat)pixbuf.Height); layer.Frame = new CGRect (right, 3, (nfloat)pixbuf.Width, (nfloat)pixbuf.Height); var area = new NSTrackingArea (layer.Frame, NSTrackingAreaOptions.MouseEnteredAndExited | NSTrackingAreaOptions.ActiveInActiveApp, this, null); AddTrackingArea (area); var statusIcon = new StatusIcon (this, layer, area) { Image = pixbuf, }; layerToStatus [layer.Name] = statusIcon; Layer.AddSublayer (layer); textField.SetFrameSize (new CGSize (right - 6 - textField.Frame.Left, Frame.Height)); return statusIcon; } public StatusBarContext CreateContext () { return ctxHandler.CreateContext (); } public void ShowReady () { ShowMessage (null, "", false, NSColor.DisabledControlText); } static Pad sourcePad; public void SetMessageSourcePad (Pad pad) { sourcePad = pad; } public void ShowError (string error) { ShowMessage (Stock.StatusError, error, false, NSColor.FromDeviceRgba (228f / 255, 84f / 255, 55f / 255, 1)); } public void ShowWarning (string warning) { ShowMessage (Stock.StatusWarning, warning, false, NSColor.FromDeviceRgba (235f / 255, 161f / 255, 7f / 255, 1)); } public void ShowMessage (string message) { ShowMessage (null, message, false, NSColor.FromRgba (0.34f, 0.34f, 0.34f, 1)); } public void ShowMessage (string message, bool isMarkup) { ShowMessage (null, message, true, NSColor.FromRgba (0.34f, 0.34f, 0.34f, 1)); } public void ShowMessage (IconId image, string message) { ShowMessage (image, message, false, NSColor.FromRgba (0.34f, 0.34f, 0.34f, 1)); } public void ShowMessage (IconId image, string message, bool isMarkup) { ShowMessage (image, message, isMarkup, NSColor.FromRgba (0.34f, 0.34f, 0.34f, 1)); } public void ShowMessage (IconId image, string message, bool isMarkup, NSColor color) { DispatchService.AssertGuiThread (); LoadText (message, isMarkup, color); LoadPixbuf (image); ReconstructString (); } void LoadText (string message, bool isMarkup, NSColor color) { message = message ?? ""; text = message.Replace (Environment.NewLine, " ").Replace ("\n", " ").Trim (); currentTextIsMarkup = isMarkup; textColor = color; } static bool iconLoaded; void LoadPixbuf (IconId iconId) { // We dont need to load the same image twice if (icon == iconId && iconLoaded) return; icon = iconId; iconAnimation = null; // clean up previous running animation if (xwtAnimation != null) { xwtAnimation.Dispose (); xwtAnimation = null; } // if we have nothing, use the default icon if (iconId == IconId.Null) iconId = Stock.StatusSteady; // load image now if (ImageService.IsAnimation (iconId, Gtk.IconSize.Menu)) { iconAnimation = ImageService.GetAnimatedIcon (iconId, Gtk.IconSize.Menu); image = iconAnimation.FirstFrame.ToNSImage (); xwtAnimation = iconAnimation.StartAnimation (p => { image = p.ToNSImage (); ReconstructString (); }); } else { image = ImageService.GetIcon (iconId).ToNSImage (); } iconLoaded = true; } public void BeginProgress (string name) { EndProgress (); ShowMessage (name); oldFraction = 0; if (AutoPulse) StartProgressAutoPulse (); } public void BeginProgress (IconId image, string name) { EndProgress (); ShowMessage (image, name); oldFraction = 0; if (AutoPulse) StartProgressAutoPulse (); } bool inProgress; double oldFraction; public void SetProgressFraction (double work) { if (AutoPulse) return; progressMarks.Push (work); if (!inProgress) { inProgress = true; StartProgress (progressMarks.Peek ()); } } public void EndProgress () { progressMarks.Clear (); if (ProgressLayer != null) { ProgressLayer.RemoveAnimation (growthAnimationKey); if (inProgress == false) ProgressLayer.RemoveFromSuperLayer (); } inProgress = false; AutoPulse = false; } public void Pulse () { // Nothing to do here. } public MonoDevelop.Ide.StatusBar MainContext { get { return ctxHandler.MainContext; } } public bool AutoPulse { get; set; } static CGColor xamBlue = new CGColor (52f / 255, 152f / 255, 219f / 255); CALayer CreateProgressBarLayer (double width) { CALayer progress = ProgressLayer; if (progress == null) { progress = CALayer.Create (); progress.Name = ProgressLayerId; progress.BackgroundColor = xamBlue; progress.BorderColor = xamBlue; progress.FillMode = CAFillMode.Forwards; progress.Frame = new CGRect (0, Frame.Height - barHeight, (nfloat)width, barHeight); } return progress; } CAAnimation CreateMoveAndGrowAnimation (CALayer progress, double growToFraction) { CAAnimationGroup grp = CAAnimationGroup.CreateAnimation (); grp.Duration = 0.2; grp.FillMode = CAFillMode.Forwards; grp.RemovedOnCompletion = false; CABasicAnimation move = CABasicAnimation.FromKeyPath ("position.x"); double oldOffset = (progress.Frame.Width / 2) * oldFraction; double newOffset = (progress.Frame.Width / 2) * growToFraction; move.From = NSNumber.FromDouble (oldOffset); move.To = NSNumber.FromDouble (newOffset); CABasicAnimation grow = CABasicAnimation.FromKeyPath ("bounds"); grow.From = NSValue.FromCGRect (new CGRect (0, 0, progress.Frame.Width * (nfloat)oldFraction, barHeight)); grow.To = NSValue.FromCGRect (new CGRect (0, 0, progress.Frame.Width * (nfloat)growToFraction, barHeight)); grp.Animations = new [] { move, grow, }; return grp; } CAAnimation CreateAutoPulseAnimation () { CABasicAnimation move = CABasicAnimation.FromKeyPath ("position.x"); move.From = NSNumber.FromDouble (-frameAutoPulseWidth); move.To = NSNumber.FromDouble (Layer.Frame.Width + frameAutoPulseWidth); move.RepeatCount = float.PositiveInfinity; move.RemovedOnCompletion = false; move.Duration = 4; return move; } void AttachFadeoutAnimation (CALayer progress, CAAnimation animation, Func fadeoutVerifier) { animation.AnimationStopped += (sender, e) => { if (!fadeoutVerifier ()) return; CABasicAnimation fadeout = CABasicAnimation.FromKeyPath ("opacity"); fadeout.From = NSNumber.FromDouble (1); fadeout.To = NSNumber.FromDouble (0); fadeout.Duration = 0.5; fadeout.FillMode = CAFillMode.Forwards; fadeout.RemovedOnCompletion = false; fadeout.AnimationStopped += (sender2, e2) => { if (!e2.Finished) return; inProgress = false; progress.Opacity = 0; progress.RemoveAllAnimations (); progress.RemoveFromSuperLayer (); }; progress.Name = ProgressLayerFadingId; progress.AddAnimation (fadeout, "opacity"); }; progress.AddAnimation (animation, growthAnimationKey); var oldLayer = ProgressLayer; if (oldLayer == null) Layer.AddSublayer (progress); UpdateLayer (); } const int barHeight = 2; void StartProgress (double newFraction) { progressMarks.Clear (); var progress = CreateProgressBarLayer (Layer.Frame.Width); var grp = CreateMoveAndGrowAnimation (progress, newFraction); oldFraction = newFraction; AttachFadeoutAnimation (progress, grp, () => { if (oldFraction < 1 && inProgress) { if (progressMarks.Count != 0) { StartProgress (progressMarks.Peek ()); } else { inProgress = false; } return false; } return true; }); } const double frameAutoPulseWidth = 100; void StartProgressAutoPulse () { var progress = CreateProgressBarLayer (frameAutoPulseWidth); var move = CreateAutoPulseAnimation (); AttachFadeoutAnimation (progress, move, () => true); } static NSAttributedString GetPopoverString (string text) { return new NSAttributedString (text, new NSStringAttributes { ParagraphStyle = new NSMutableParagraphStyle { Alignment = NSTextAlignment.Center, }, Font = NSFont.SystemFontOfSize (NSFont.SystemFontSize - 1), }); } public override void ViewDidMoveToSuperview () { base.ViewDidMoveToSuperview (); popover.ContentViewController.View = new NSTextField { DrawsBackground = false, Bezeled = false, Editable = false, Frame = new CGRect (0, 0, 230, 30), AutoresizingMask = NSViewResizingMask.HeightSizable, Cell = new VerticallyCenteredTextFieldCell (yOffset: -1), }; } NSPopover popover = new NSPopover { ContentViewController = new NSViewController (null, null), Animates = false, }; public void ShowPopoverForLayer (CALayer layer) { if (!layerToStatus.ContainsKey (layer.Name)) return; var field = (NSTextField)popover.ContentViewController.View; string tooltip = layerToStatus [layer.Name].ToolTip; if (tooltip == null) return; field.AttributedStringValue = GetPopoverString (tooltip); popover.Show (layer.Frame, this, NSRectEdge.MinYEdge); } void DestroyPopover () { oldLayer = null; popover.Close (); } CALayer LayerForEvent (NSEvent theEvent) { CGPoint location = ConvertPointFromView (theEvent.LocationInWindow, null); CALayer layer = Layer.PresentationLayer.HitTest (location); return layer != null ? layer.ModelLayer : null; } string oldLayer; public override void MouseEntered (NSEvent theEvent) { base.MouseEntered (theEvent); StatusIcon icon; var layer = LayerForEvent (theEvent); if (layer == null || layer.Name == oldLayer) { if (!layerToStatus.TryGetValue (layer.Name, out icon)) return; if (string.IsNullOrEmpty (icon.ToolTip)) return; } oldLayer = layer.Name; ShowPopoverForLayer (layer); } public override void MouseExited (NSEvent theEvent) { base.MouseExited (theEvent); if (oldLayer != null) { DestroyPopover (); } } public override void MouseDown (NSEvent theEvent) { base.MouseDown (theEvent); var layer = LayerForEvent (theEvent); if (layer != null && layer.Name != null) { Xwt.PointerButton button = Xwt.PointerButton.Left; switch ((NSEventType)(long)theEvent.ButtonNumber) { case NSEventType.LeftMouseDown: button = Xwt.PointerButton.Left; break; case NSEventType.RightMouseDown: button = Xwt.PointerButton.Right; break; case NSEventType.OtherMouseDown: button = Xwt.PointerButton.Middle; break; } if (layerToStatus.ContainsKey (layer.Name)) { DestroyPopover (); layerToStatus [layer.Name].NotifyClicked (button); return; } } if (layer.Name == BuildIconLayerId || layer.Name == BuildTextLayerId) { // We clicked error icon. IdeApp.Workbench.GetPad ().BringToFront (); return; } if (sourcePad != null) sourcePad.BringToFront (true); } public override void ViewDidEndLiveResize () { base.ViewDidEndLiveResize (); RepositionStatusLayers (); } } }