1/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2/* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6#include "nsMenuItemX.h"
7#include "nsMenuBarX.h"
8#include "nsMenuX.h"
9#include "nsMenuItemIconX.h"
10#include "nsMenuUtilsX.h"
11#include "nsCocoaUtils.h"
12
13#include "nsObjCExceptions.h"
14
15#include "nsCOMPtr.h"
16#include "nsGkAtoms.h"
17
18#include "mozilla/dom/Element.h"
19#include "mozilla/dom/Event.h"
20#include "mozilla/ErrorResult.h"
21#include "nsIWidget.h"
22#include "mozilla/dom/Document.h"
23
24using namespace mozilla;
25
26using mozilla::dom::Event;
27using mozilla::dom::CallerType;
28
29nsMenuItemX::nsMenuItemX(nsMenuX* aParent, const nsString& aLabel, EMenuItemType aItemType,
30                         nsMenuGroupOwnerX* aMenuGroupOwner, nsIContent* aNode)
31    : mContent(aNode), mType(aItemType), mMenuParent(aParent), mMenuGroupOwner(aMenuGroupOwner) {
32  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;
33
34  MOZ_COUNT_CTOR(nsMenuItemX);
35
36  MOZ_RELEASE_ASSERT(mContent->IsElement(), "nsMenuItemX should only be created for elements");
37  NS_ASSERTION(mMenuGroupOwner, "No menu owner given, must have one!");
38
39  mMenuGroupOwner->RegisterForContentChanges(mContent, this);
40
41  dom::Document* doc = mContent->GetUncomposedDoc();
42
43  // if we have a command associated with this menu item, register for changes
44  // to the command DOM node
45  if (doc) {
46    nsAutoString ourCommand;
47    mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::command, ourCommand);
48
49    if (!ourCommand.IsEmpty()) {
50      dom::Element* commandElement = doc->GetElementById(ourCommand);
51
52      if (commandElement) {
53        mCommandElement = commandElement;
54        // register to observe the command DOM element
55        mMenuGroupOwner->RegisterForContentChanges(mCommandElement, this);
56      }
57    }
58  }
59
60  // decide enabled state based on command content if it exists, otherwise do it based
61  // on our own content
62  bool isEnabled;
63  if (mCommandElement) {
64    isEnabled = !mCommandElement->AttrValueIs(kNameSpaceID_None, nsGkAtoms::disabled,
65                                              nsGkAtoms::_true, eCaseMatters);
66  } else {
67    isEnabled = !mContent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::disabled,
68                                                    nsGkAtoms::_true, eCaseMatters);
69  }
70
71  // set up the native menu item
72  if (mType == eSeparatorMenuItemType) {
73    mNativeMenuItem = [[NSMenuItem separatorItem] retain];
74  } else {
75    NSString* newCocoaLabelString = nsMenuUtilsX::GetTruncatedCocoaLabel(aLabel);
76    mNativeMenuItem = [[NSMenuItem alloc] initWithTitle:newCocoaLabelString
77                                                 action:nil
78                                          keyEquivalent:@""];
79
80    mIsChecked = mContent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::checked,
81                                                    nsGkAtoms::_true, eCaseMatters);
82
83    mNativeMenuItem.enabled = isEnabled;
84    mNativeMenuItem.state = mIsChecked ? NSOnState : NSOffState;
85
86    SetKeyEquiv();
87  }
88
89  mIcon = MakeUnique<nsMenuItemIconX>(this);
90
91  mIsVisible = !nsMenuUtilsX::NodeIsHiddenOrCollapsed(mContent);
92
93  // All menu items share the same target and action, and are differentiated
94  // be a unique (representedObject, tag) pair.
95  mNativeMenuItem.target = nsMenuBarX::sNativeEventTarget;
96  mNativeMenuItem.action = @selector(menuItemHit:);
97  mNativeMenuItem.representedObject = mMenuGroupOwner->GetRepresentedObject();
98  mNativeMenuItem.tag = mMenuGroupOwner->RegisterForCommand(this);
99
100  if (mIsVisible) {
101    SetupIcon();
102  }
103
104  NS_OBJC_END_TRY_ABORT_BLOCK;
105}
106
107nsMenuItemX::~nsMenuItemX() {
108  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;
109
110  // autorelease the native menu item so that anything else happening to this
111  // object happens before the native menu item actually dies
112  [mNativeMenuItem autorelease];
113
114  DetachFromGroupOwner();
115
116  MOZ_COUNT_DTOR(nsMenuItemX);
117
118  NS_OBJC_END_TRY_ABORT_BLOCK;
119}
120
121void nsMenuItemX::DetachFromGroupOwner() {
122  if (mMenuGroupOwner) {
123    mMenuGroupOwner->UnregisterCommand(mNativeMenuItem.tag);
124
125    if (mContent) {
126      mMenuGroupOwner->UnregisterForContentChanges(mContent);
127    }
128    if (mCommandElement) {
129      mMenuGroupOwner->UnregisterForContentChanges(mCommandElement);
130    }
131  }
132
133  mMenuGroupOwner = nullptr;
134}
135
136nsresult nsMenuItemX::SetChecked(bool aIsChecked) {
137  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;
138
139  mIsChecked = aIsChecked;
140
141  // update the content model. This will also handle unchecking our siblings
142  // if we are a radiomenu
143  if (mIsChecked) {
144    mContent->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::checked, u"true"_ns, true);
145  } else {
146    mContent->AsElement()->UnsetAttr(kNameSpaceID_None, nsGkAtoms::checked, true);
147  }
148
149  // update native menu item
150  mNativeMenuItem.state = mIsChecked ? NSOnState : NSOffState;
151
152  return NS_OK;
153
154  NS_OBJC_END_TRY_ABORT_BLOCK;
155}
156
157EMenuItemType nsMenuItemX::GetMenuItemType() { return mType; }
158
159// Executes the "cached" javaScript command.
160// Returns NS_OK if the command was executed properly, otherwise an error code.
161void nsMenuItemX::DoCommand(NSEventModifierFlags aModifierFlags, int16_t aButton) {
162  // flip "checked" state if we're a checkbox menu, or an un-checked radio menu
163  if (mType == eCheckboxMenuItemType || (mType == eRadioMenuItemType && !mIsChecked)) {
164    if (!mContent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::autocheck,
165                                            nsGkAtoms::_false, eCaseMatters)) {
166      SetChecked(!mIsChecked);
167    }
168    /* the AttributeChanged code will update all the internal state */
169  }
170
171  nsMenuUtilsX::DispatchCommandTo(mContent, aModifierFlags, aButton);
172}
173
174nsresult nsMenuItemX::DispatchDOMEvent(const nsString& eventName, bool* preventDefaultCalled) {
175  if (!mContent) {
176    return NS_ERROR_FAILURE;
177  }
178
179  // get owner document for content
180  nsCOMPtr<dom::Document> parentDoc = mContent->OwnerDoc();
181
182  // create DOM event
183  ErrorResult rv;
184  RefPtr<Event> event = parentDoc->CreateEvent(u"Events"_ns, CallerType::System, rv);
185  if (rv.Failed()) {
186    NS_WARNING("Failed to create Event");
187    return rv.StealNSResult();
188  }
189  event->InitEvent(eventName, true, true);
190
191  // mark DOM event as trusted
192  event->SetTrusted(true);
193
194  // send DOM event
195  *preventDefaultCalled = mContent->DispatchEvent(*event, CallerType::System, rv);
196  if (rv.Failed()) {
197    NS_WARNING("Failed to send DOM event via EventTarget");
198    return rv.StealNSResult();
199  }
200
201  return NS_OK;
202}
203
204// Walk the sibling list looking for nodes with the same name and
205// uncheck them all.
206void nsMenuItemX::UncheckRadioSiblings(nsIContent* aCheckedContent) {
207  nsAutoString myGroupName;
208  aCheckedContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::name, myGroupName);
209  if (!myGroupName.Length()) {  // no groupname, nothing to do
210    return;
211  }
212
213  nsCOMPtr<nsIContent> parent = aCheckedContent->GetParent();
214  if (!parent) {
215    return;
216  }
217
218  // loop over siblings
219  for (nsIContent* sibling = parent->GetFirstChild(); sibling;
220       sibling = sibling->GetNextSibling()) {
221    if (sibling != aCheckedContent && sibling->IsElement()) {  // skip this node
222      // if the current sibling is in the same group, clear it
223      if (sibling->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::name, myGroupName,
224                                            eCaseMatters)) {
225        sibling->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::checked, u"false"_ns, true);
226      }
227    }
228  }
229}
230
231void nsMenuItemX::SetKeyEquiv() {
232  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;
233
234  // Set key shortcut and modifiers
235  nsAutoString keyValue;
236  mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::key, keyValue);
237
238  if (!keyValue.IsEmpty() && mContent->GetUncomposedDoc()) {
239    dom::Element* keyContent = mContent->GetUncomposedDoc()->GetElementById(keyValue);
240    if (keyContent) {
241      nsAutoString keyChar;
242      bool hasKey = keyContent->GetAttr(kNameSpaceID_None, nsGkAtoms::key, keyChar);
243
244      if (!hasKey || keyChar.IsEmpty()) {
245        nsAutoString keyCodeName;
246        keyContent->GetAttr(kNameSpaceID_None, nsGkAtoms::keycode, keyCodeName);
247        uint32_t charCode = nsCocoaUtils::ConvertGeckoNameToMacCharCode(keyCodeName);
248        if (charCode) {
249          keyChar.Assign(charCode);
250        } else {
251          keyChar.AssignLiteral(u" ");
252        }
253      }
254
255      nsAutoString modifiersStr;
256      keyContent->GetAttr(kNameSpaceID_None, nsGkAtoms::modifiers, modifiersStr);
257      uint8_t modifiers = nsMenuUtilsX::GeckoModifiersForNodeAttribute(modifiersStr);
258
259      unsigned int macModifiers = nsMenuUtilsX::MacModifiersForGeckoModifiers(modifiers);
260      mNativeMenuItem.keyEquivalentModifierMask = macModifiers;
261
262      NSString* keyEquivalent = [[NSString stringWithCharacters:(unichar*)keyChar.get()
263                                                         length:keyChar.Length()] lowercaseString];
264      if ([keyEquivalent isEqualToString:@" "]) {
265        mNativeMenuItem.keyEquivalent = @"";
266      } else {
267        mNativeMenuItem.keyEquivalent = keyEquivalent;
268      }
269
270      return;
271    }
272  }
273
274  // if the key was removed, clear the key
275  mNativeMenuItem.keyEquivalent = @"";
276
277  NS_OBJC_END_TRY_ABORT_BLOCK;
278}
279
280void nsMenuItemX::Dump(uint32_t aIndent) const {
281  printf("%*s - item [%p] %-16s <%s>\n", aIndent * 2, "", this,
282         mType == eSeparatorMenuItemType ? "----" : [mNativeMenuItem.title UTF8String],
283         NS_ConvertUTF16toUTF8(mContent->NodeName()).get());
284}
285
286//
287// nsChangeObserver
288//
289
290void nsMenuItemX::ObserveAttributeChanged(dom::Document* aDocument, nsIContent* aContent,
291                                          nsAtom* aAttribute) {
292  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;
293
294  if (!aContent) {
295    return;
296  }
297
298  if (aContent == mContent) {  // our own content node changed
299    if (aAttribute == nsGkAtoms::checked) {
300      // if we're a radio menu, uncheck our sibling radio items. No need to
301      // do any of this if we're just a normal check menu.
302      if (mType == eRadioMenuItemType &&
303          mContent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::checked,
304                                             nsGkAtoms::_true, eCaseMatters)) {
305        UncheckRadioSiblings(mContent);
306      }
307      mMenuParent->SetRebuild(true);
308    } else if (aAttribute == nsGkAtoms::hidden || aAttribute == nsGkAtoms::collapsed) {
309      bool isVisible = !nsMenuUtilsX::NodeIsHiddenOrCollapsed(mContent);
310      if (isVisible != mIsVisible) {
311        mIsVisible = isVisible;
312        RefPtr<nsMenuItemX> self = this;
313        mMenuParent->MenuChildChangedVisibility(nsMenuParentX::MenuChild(self), isVisible);
314        if (mIsVisible) {
315          SetupIcon();
316        }
317      }
318      mMenuParent->SetRebuild(true);
319    } else if (aAttribute == nsGkAtoms::label) {
320      if (mType != eSeparatorMenuItemType) {
321        nsAutoString newLabel;
322        mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::label, newLabel);
323        mNativeMenuItem.title = nsMenuUtilsX::GetTruncatedCocoaLabel(newLabel);
324      }
325    } else if (aAttribute == nsGkAtoms::key) {
326      SetKeyEquiv();
327    } else if (aAttribute == nsGkAtoms::image) {
328      SetupIcon();
329    } else if (aAttribute == nsGkAtoms::disabled) {
330      mNativeMenuItem.enabled = !aContent->AsElement()->AttrValueIs(
331          kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true, eCaseMatters);
332    }
333  } else if (aContent == mCommandElement) {
334    // the only thing that really matters when the menu isn't showing is the
335    // enabled state since it enables/disables keyboard commands
336    if (aAttribute == nsGkAtoms::disabled) {
337      // first we sync our menu item DOM node with the command DOM node
338      nsAutoString commandDisabled;
339      nsAutoString menuDisabled;
340      aContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::disabled, commandDisabled);
341      mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::disabled, menuDisabled);
342      if (!commandDisabled.Equals(menuDisabled)) {
343        // The menu's disabled state needs to be updated to match the command.
344        if (commandDisabled.IsEmpty()) {
345          mContent->AsElement()->UnsetAttr(kNameSpaceID_None, nsGkAtoms::disabled, true);
346        } else {
347          mContent->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::disabled, commandDisabled,
348                                         true);
349        }
350      }
351      // now we sync our native menu item with the command DOM node
352      mNativeMenuItem.enabled = !aContent->AsElement()->AttrValueIs(
353          kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true, eCaseMatters);
354    }
355  }
356
357  NS_OBJC_END_TRY_ABORT_BLOCK;
358}
359
360bool IsMenuStructureElement(nsIContent* aContent) {
361  return aContent->IsAnyOfXULElements(nsGkAtoms::menu, nsGkAtoms::menuitem,
362                                      nsGkAtoms::menuseparator);
363}
364
365void nsMenuItemX::ObserveContentRemoved(dom::Document* aDocument, nsIContent* aContainer,
366                                        nsIContent* aChild, nsIContent* aPreviousSibling) {
367  MOZ_RELEASE_ASSERT(mMenuGroupOwner);
368  MOZ_RELEASE_ASSERT(mMenuParent);
369
370  if (aChild == mCommandElement) {
371    mMenuGroupOwner->UnregisterForContentChanges(mCommandElement);
372    mCommandElement = nullptr;
373  }
374  if (IsMenuStructureElement(aChild)) {
375    mMenuParent->SetRebuild(true);
376  }
377}
378
379void nsMenuItemX::ObserveContentInserted(dom::Document* aDocument, nsIContent* aContainer,
380                                         nsIContent* aChild) {
381  MOZ_RELEASE_ASSERT(mMenuParent);
382
383  // The child node could come from the custom element that is for display, so
384  // only rebuild the menu if the child is related to the structure of the
385  // menu.
386  if (IsMenuStructureElement(aChild)) {
387    mMenuParent->SetRebuild(true);
388  }
389}
390
391void nsMenuItemX::SetupIcon() {
392  if (mType != eRegularMenuItemType) {
393    // Don't support icons on checkbox and radio menuitems, for consistency with Windows & Linux.
394    return;
395  }
396
397  mIcon->SetupIcon(mContent);
398  mNativeMenuItem.image = mIcon->GetIconImage();
399}
400
401void nsMenuItemX::IconUpdated() { mNativeMenuItem.image = mIcon->GetIconImage(); }
402