/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ts=8 sts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// eSetSelection events from the Fennec widget IME can be generated // by autoSuggest / autoCorrect composition changes, or by TYPE_REPLACE_TEXT // actions, either positioning cursor for text insert, or selecting // text-to-be-replaced. None should affect AccessibleCaret visibility. if (aReason & nsISelectionListener::IME_REASON) { return NS_OK;
}
// Move the cursor by JavaScript or unknown internal call. if (aReason == nsISelectionListener::NO_REASON ||
aReason == nsISelectionListener::JS_REASON) { auto mode = static_cast<ScriptUpdateMode>(
StaticPrefs::layout_accessiblecaret_script_change_update_mode()); if (mode == kScriptAlwaysShow ||
(mode == kScriptUpdateVisible && mCarets.HasLogicallyVisibleCaret())) {
UpdateCarets(); return NS_OK;
} // Default for NO_REASON is to make hidden.
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
// Move cursor by keyboard. if (aReason & nsISelectionListener::KEYPRESS_REASON) {
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
// OnBlur() might be called between mouse down and mouse up, so we hide carets // upon mouse down anyway, and update carets upon mouse up. if (aReason & nsISelectionListener::MOUSEDOWN_REASON) {
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
// Range will collapse after cutting or copying text. if (aReason & (nsISelectionListener::COLLAPSETOSTART_REASON |
nsISelectionListener::COLLAPSETOEND_REASON)) {
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
// For mouse input we don't want to show the carets. if (StaticPrefs::layout_accessiblecaret_hide_carets_for_mouse_input() &&
mLastInputSource == MouseEvent_Binding::MOZ_SOURCE_MOUSE) {
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
// When we want to hide the carets for mouse input, hide them for select // all action fired by keyboard as well. if (StaticPrefs::layout_accessiblecaret_hide_carets_for_mouse_input() &&
mLastInputSource == MouseEvent_Binding::MOZ_SOURCE_KEYBOARD &&
(aReason & nsISelectionListener::SELECTALL_REASON)) {
HideCaretsAndDispatchCaretStateChangedEvent(); return NS_OK;
}
auto AccessibleCaretManager::MaybeFlushLayout() -> Terminated { if (mPresShell) { // `MaybeFlush` doesn't access the PresShell after flushing, so it's OK to // mark it as live.
mLayoutFlusher.MaybeFlush(MOZ_KnownLive(*mPresShell));
}
PositionChangedResult result = mCarets.GetFirst()->SetPosition(frame, offset);
switch (result) { case PositionChangedResult::NotChanged: case PositionChangedResult::Position: case PositionChangedResult::Zoom: if (!aHints.contains(UpdateCaretsHint::RespectOldAppearance)) { if (HasNonEmptyTextContent(GetEditingHostForFrame(frame))) {
mCarets.GetFirst()->SetAppearance(Appearance::Normal);
} elseif (
StaticPrefs::
layout_accessiblecaret_caret_shown_when_long_tapping_on_empty_content()) { if (mCarets.GetFirst()->IsLogicallyVisible()) { // Possible cases are: 1) SelectWordOrShortcut() sets the // appearance to Normal. 2) When the caret is out of viewport and // now scrolling into viewport, it has appearance NormalNotShown.
mCarets.GetFirst()->SetAppearance(Appearance::Normal);
} else { // Possible cases are: a) Single tap on current empty content; // OnSelectionChanged() sets the appearance to None due to // MOUSEDOWN_REASON. b) Single tap on other empty content; // OnBlur() sets the appearance to None. // // Do nothing to make the appearance remains None so that it can // be distinguished from case 2). Also do not set the appearance // to NormalNotShown here like the default update behavior.
}
} else {
mCarets.GetFirst()->SetAppearance(Appearance::NormalNotShown);
}
} break;
case PositionChangedResult::Invisible:
mCarets.GetFirst()->SetAppearance(Appearance::NormalNotShown); break;
}
if (!CompareTreePosition(startFrame, endFrame)) { // XXX: Do we really have to hide carets if this condition isn't satisfied?
HideCaretsAndDispatchCaretStateChangedEvent(); return;
}
auto updateSingleCaret = [aHints](AccessibleCaret* aCaret, nsIFrame* aFrame,
int32_t aOffset) -> PositionChangedResult {
PositionChangedResult result = aCaret->SetPosition(aFrame, aOffset);
switch (result) { case PositionChangedResult::NotChanged: case PositionChangedResult::Position: case PositionChangedResult::Zoom: if (!aHints.contains(UpdateCaretsHint::RespectOldAppearance)) {
aCaret->SetAppearance(Appearance::Normal);
} break;
case PositionChangedResult::Invisible:
aCaret->SetAppearance(Appearance::NormalNotShown); break;
} return result;
};
if (mIsCaretPositionChanged) { // Flush layout to make the carets intersection correct. if (MaybeFlushLayout() == Terminated::Yes) { return;
}
}
if (!aHints.contains(UpdateCaretsHint::RespectOldAppearance)) { // Only check for tilt carets when the caller doesn't ask us to preserve // old appearance. Otherwise we might override the appearance set by the // caller. if (StaticPrefs::layout_accessiblecaret_always_tilt()) {
UpdateCaretsForAlwaysTilt(startFrame, endFrame);
} else {
UpdateCaretsForOverlappingTilt();
}
}
if (!aHints.contains(UpdateCaretsHint::DispatchNoEvent) && !mActiveCaret) {
DispatchCaretStateChangedEvent(CaretChangedReason::Updateposition);
}
}
void AccessibleCaretManager::DesiredAsyncPanZoomState::Update( const AccessibleCaretManager& aAccessibleCaretManager) { if (aAccessibleCaretManager.mActiveCaret) { // No need to disable APZ when dragging the caret.
mValue = Value::Enabled; return;
}
if (aAccessibleCaretManager.mIsScrollStarted) { // During scrolling, the caret's position is changed only if it is in a // position:fixed or a "stuck" position:sticky frame subtree.
mValue = aAccessibleCaretManager.mIsCaretPositionChanged ? Value::Disabled
: Value::Enabled; return;
}
// For other cases, we can only reliably detect whether the caret is in a // position:fixed frame subtree. switch (aAccessibleCaretManager.mLastUpdateCaretMode) { case CaretMode::None:
mValue = Value::Enabled; break; case CaretMode::Cursor:
mValue =
(aAccessibleCaretManager.mCarets.GetFirst()->IsVisuallyVisible() &&
aAccessibleCaretManager.mCarets.GetFirst()
->IsInPositionFixedSubtree())
? Value::Disabled
: Value::Enabled; break; case CaretMode::Selection:
mValue =
((aAccessibleCaretManager.mCarets.GetFirst()->IsVisuallyVisible() &&
aAccessibleCaretManager.mCarets.GetFirst()
->IsInPositionFixedSubtree()) ||
(aAccessibleCaretManager.mCarets.GetSecond()->IsVisuallyVisible() &&
aAccessibleCaretManager.mCarets.GetSecond()
->IsInPositionFixedSubtree()))
? Value::Disabled
: Value::Enabled; break;
}
}
bool AccessibleCaretManager::UpdateCaretsForOverlappingTilt() { if (!mCarets.GetFirst()->IsVisuallyVisible() ||
!mCarets.GetSecond()->IsVisuallyVisible()) { returnfalse;
}
if (!mCarets.GetFirst()->Intersects(*mCarets.GetSecond())) {
mCarets.GetFirst()->SetAppearance(Appearance::Normal);
mCarets.GetSecond()->SetAppearance(Appearance::Normal); returnfalse;
}
void AccessibleCaretManager::UpdateCaretsForAlwaysTilt( const nsIFrame* aStartFrame, const nsIFrame* aEndFrame) { // When a short LTR word in RTL environment is selected, the two carets // tilted inward might be overlapped. Make them tilt outward. if (UpdateCaretsForOverlappingTilt()) { return;
}
if (mCarets.GetFirst()->IsVisuallyVisible()) { auto startFrameWritingMode = aStartFrame->GetWritingMode();
mCarets.GetFirst()->SetAppearance(startFrameWritingMode.IsBidiLTR()
? Appearance::Left
: Appearance::Right);
} if (mCarets.GetSecond()->IsVisuallyVisible()) { auto endFrameWritingMode = aEndFrame->GetWritingMode();
mCarets.GetSecond()->SetAppearance(
endFrameWritingMode.IsBidiLTR() ? Appearance::Right : Appearance::Left);
}
}
void AccessibleCaretManager::ProvideHapticFeedback() { if (StaticPrefs::layout_accessiblecaret_hapticfeedback()) { if (nsCOMPtr<nsIHapticFeedback> haptic =
do_GetService("@mozilla.org/widget/hapticfeedback;1")) {
haptic->PerformSimpleAction(haptic->LongPress);
}
}
}
nsresult AccessibleCaretManager::SelectWordOrShortcut(const nsPoint& aPoint) { // If the long-tap is landing on a pre-existing selection, don't replace // it with a new one. Instead just return and let the context menu pop up // on the pre-existing selection. if (GetCaretMode() == CaretMode::Selection &&
GetSelection()->ContainsPoint(aPoint)) {
AC_LOG("%s: UpdateCarets() for current selection", __FUNCTION__);
UpdateCarets();
ProvideHapticFeedback(); return NS_OK;
}
if (!mPresShell) { return NS_ERROR_UNEXPECTED;
}
nsIFrame* rootFrame = mPresShell->GetRootFrame(); if (!rootFrame) { return NS_ERROR_NOT_AVAILABLE;
}
// Find the frame under point.
AutoWeakFrame ptFrame = nsLayoutUtils::GetFrameForPoint(
RelativeTo{rootFrame}, aPoint, GetHitTestOptions()); if (!ptFrame.GetFrame()) { return NS_ERROR_FAILURE;
}
#ifdef DEBUG_FRAME_DUMP
AC_LOG("%s: Found %s under (%d, %d)", __FUNCTION__, ptFrame->ListTag().get(),
aPoint.x, aPoint.y);
AC_LOG("%s: Found %s focusable", __FUNCTION__,
focusableFrame ? focusableFrame->ListTag().get() : "no frame"); #endif
// Get ptInFrame here so that we don't need to check whether rootFrame is // alive later. Note that if ptFrame is being moved by // IMEStateManager::NotifyIME() or ChangeFocusToOrClearOldFocus() below, // something under the original point will be selected, which may not be the // original text the user wants to select.
nsPoint ptInFrame = aPoint;
nsLayoutUtils::TransformPoint(RelativeTo{rootFrame}, RelativeTo{ptFrame},
ptInFrame);
// Firstly check long press on an empty editable content.
Element* newFocusEditingHost = GetEditingHostForFrame(ptFrame); if (focusableFrame && newFocusEditingHost &&
!HasNonEmptyTextContent(newFocusEditingHost)) {
ChangeFocusToOrClearOldFocus(focusableFrame);
if (StaticPrefs::
layout_accessiblecaret_caret_shown_when_long_tapping_on_empty_content()) {
mCarets.GetFirst()->SetAppearance(Appearance::Normal);
} // We need to update carets to get correct information before dispatching // CaretStateChangedEvent.
UpdateCarets();
ProvideHapticFeedback();
DispatchCaretStateChangedEvent(CaretChangedReason::Longpressonemptycontent); return NS_OK;
}
// Commit the composition string of the old editable focus element (if there // is any) before changing the focus.
IMEStateManager::NotifyIME(widget::REQUEST_TO_COMMIT_COMPOSITION,
mPresShell->GetPresContext()); if (!ptFrame.IsAlive()) { // Cannot continue because ptFrame died. return NS_ERROR_FAILURE;
}
// ptFrame is selectable. Now change the focus.
ChangeFocusToOrClearOldFocus(focusableFrame); if (!ptFrame.IsAlive()) { // Cannot continue because ptFrame died. return NS_ERROR_FAILURE;
}
// If long tap point isn't selectable frame for caret and frame selection // can find a better frame for caret, we don't select a word. // See https://webcompat.com/issues/15953
nsIFrame::ContentOffsets offsets = ptFrame->GetContentOffsetsFromPoint(
ptInFrame,
nsIFrame::SKIP_HIDDEN | nsIFrame::IGNORE_NATIVE_ANONYMOUS_SUBTREE); if (offsets.content) {
RefPtr<nsFrameSelection> frameSelection = GetFrameSelection(); if (frameSelection) {
nsIFrame* theFrame = SelectionMovementUtils::GetFrameForNodeOffset(
offsets.content, offsets.offset, offsets.associate); if (theFrame && theFrame != ptFrame) {
SetSelectionDragState(true);
frameSelection->HandleClick(
MOZ_KnownLive(offsets.content) /* bug 1636889 */,
offsets.StartOffset(), offsets.EndOffset(),
nsFrameSelection::FocusMode::kCollapseToNewPoint,
offsets.associate);
SetSelectionDragState(false);
ClearMaintainedSelection();
if (StaticPrefs::
layout_accessiblecaret_caret_shown_when_long_tapping_on_empty_content()) {
mCarets.GetFirst()->SetAppearance(Appearance::Normal);
}
Maybe<PresShell::AutoAssertNoFlush> assert; if (mPresShell) {
assert.emplace(*mPresShell);
}
mIsScrollStarted = true;
if (mCarets.HasLogicallyVisibleCaret()) { // Dispatch the event only if one of the carets is logically visible like in // HideCaretsAndDispatchCaretStateChangedEvent().
DispatchCaretStateChangedEvent(CaretChangedReason::Scroll);
}
}
Maybe<PresShell::AutoAssertNoFlush> assert; if (mPresShell) {
assert.emplace(*mPresShell);
}
mIsScrollStarted = false;
if (GetCaretMode() == CaretMode::Cursor) { if (!mCarets.GetFirst()->IsLogicallyVisible()) { // If the caret is hidden (Appearance::None) due to blur, no // need to update it. return;
}
}
// For mouse and keyboard input, we don't want to show the carets. if (StaticPrefs::layout_accessiblecaret_hide_carets_for_mouse_input() &&
(mLastInputSource == MouseEvent_Binding::MOZ_SOURCE_MOUSE ||
mLastInputSource == MouseEvent_Binding::MOZ_SOURCE_KEYBOARD)) {
AC_LOG("%s: HideCaretsAndDispatchCaretStateChangedEvent()", __FUNCTION__);
HideCaretsAndDispatchCaretStateChangedEvent(); return;
}
Maybe<PresShell::AutoAssertNoFlush> assert; if (mPresShell) {
assert.emplace(*mPresShell);
}
if (mCarets.HasLogicallyVisibleCaret()) { if (mIsScrollStarted) { // We don't want extra CaretStateChangedEvents dispatched when user is // scrolling the page.
AC_LOG("%s: UpdateCarets(RespectOldAppearance | DispatchNoEvent)",
__FUNCTION__);
UpdateCarets({UpdateCaretsHint::RespectOldAppearance,
UpdateCaretsHint::DispatchNoEvent});
} else {
AC_LOG("%s: UpdateCarets(RespectOldAppearance)", __FUNCTION__);
UpdateCarets(UpdateCaretsHint::RespectOldAppearance);
}
}
}
already_AddRefed<nsFrameSelection> AccessibleCaretManager::GetFrameSelection() const { if (!mPresShell) { return nullptr;
}
// Prevent us from touching the nsFrameSelection associated with other // PresShell.
RefPtr<nsFrameSelection> fs = mPresShell->GetLastFocusedFrameSelection(); if (!fs || fs->GetPresShell() != mPresShell) { return nullptr;
}
const nsFocusManager* fm = nsFocusManager::GetFocusManager();
MOZ_ASSERT(fm); if (fm->GetFocusedWindow() != mPresShell->GetDocument()->GetWindow()) { // Hide carets if the window is not focused. return CaretMode::None;
}
if (selection->IsCollapsed()) { return CaretMode::Cursor;
}
return CaretMode::Selection;
}
nsIFrame* AccessibleCaretManager::GetFocusableFrame(nsIFrame* aFrame) const { // This implementation is similar to EventStateManager::PostHandleEvent(). // Look for the nearest enclosing focusable frame.
nsIFrame* focusableFrame = aFrame; while (focusableFrame) { if (focusableFrame->IsFocusable(IsFocusableFlags::WithMouse)) { break;
}
focusableFrame = focusableFrame->GetParent();
} return focusableFrame;
}
// Extend the phone number selection until we find a boundary.
RefPtr<Selection> selection = GetSelection();
while (selection) { const nsRange* anchorFocusRange = selection->GetAnchorFocusRange(); if (!anchorFocusRange) { return;
}
// Backup the anchor focus range since both anchor node and focus node might // be changed after calling Selection::Modify().
RefPtr<nsRange> oldAnchorFocusRange = anchorFocusRange->CloneRange();
// Save current focus node, focus offset and the selected text so that // we can compare them with the modified ones later.
nsINode* oldFocusNode = selection->GetFocusNode();
uint32_t oldFocusOffset = selection->FocusOffset();
nsAutoString oldSelectedText = StringifiedSelection();
// Extend the selection by one char.
selection->Modify(u"extend"_ns, aDirection, u"character"_ns,
IgnoreErrors()); if (IsTerminated() == Terminated::Yes) { return;
}
// If the selection didn't change, (can't extend further), we're done. if (selection->GetFocusNode() == oldFocusNode &&
selection->FocusOffset() == oldFocusOffset) { return;
}
// If the changed selection isn't a valid phone number, we're done. // Also, if the selection was extended to a new block node, the string // returned by stringify() won't have a new line at the beginning or the // end of the string. Therefore, if either focus node or offset is // changed, but selected text is not changed, we're done, too.
nsAutoString selectedText = StringifiedSelection();
if (!IsPhoneNumber(selectedText) || oldSelectedText == selectedText) { // Backout the undesired selection extend, restore the old anchor focus // range before exit.
selection->SetAnchorFocusToRange(oldAnchorFocusRange); return;
}
}
}
void AccessibleCaretManager::ClearMaintainedSelection() const { // Selection made by double-clicking for example will maintain the original // word selection. We should clear it so that we can drag caret freely.
RefPtr<nsFrameSelection> fs = GetFrameSelection(); if (fs) {
fs->MaintainSelection(eSelectNoAmount);
}
}
if (Document* doc = aPresShell.GetDocument()) {
doc->FlushPendingNotifications(FlushType::Layout); // Don't access the PresShell after flushing, it could've become invalid.
}
}
}
// We are walking among the nodes in the content tree, so the node offset // relative to startNode should be set to 0.
nodeOffset = 0;
*aOutOffset = 0;
}
if (startFrame) { if (aOutContent) {
startContent.forget(aOutContent);
} if (aOutContentOffset) {
*aOutContentOffset = nodeOffset;
}
}
return startFrame;
}
bool AccessibleCaretManager::RestrictCaretDraggingOffsets(
nsIFrame::ContentOffsets& aOffsets) { if (!mPresShell) { returnfalse;
}
// Compare the active caret's new position (aOffsets) to the inactive caret's // position.
NS_ASSERTION(contentOffset >= 0, "contentOffset should not be negative"); const Maybe<int32_t> cmpToInactiveCaretPos =
nsContentUtils::ComparePoints_AllowNegativeOffsets(
aOffsets.content, aOffsets.StartOffset(), content, contentOffset); if (NS_WARN_IF(!cmpToInactiveCaretPos)) { // Potentially handle this properly when Selection across Shadow DOM // boundary is implemented // (https://bugzilla.mozilla.org/show_bug.cgi?id=1607497). returnfalse;
}
// Move one character (in the direction of dir) from the inactive caret's // position. This is the limit for the active caret's new position.
PeekOffsetStruct limit(
eSelectCluster, dir, offset, nsPoint(0, 0),
{PeekOffsetOption::JumpLines, PeekOffsetOption::StopAtScroller});
nsresult rv = frame->PeekOffset(&limit); if (NS_FAILED(rv)) {
limit.mResultContent = content;
limit.mContentOffset = contentOffset;
}
// Compare the active caret's new position (aOffsets) to the limit.
NS_ASSERTION(limit.mContentOffset >= 0, "limit.mContentOffset should not be negative"); const Maybe<int32_t> cmpToLimit =
nsContentUtils::ComparePoints_AllowNegativeOffsets(
aOffsets.content, aOffsets.StartOffset(), limit.mResultContent,
limit.mContentOffset); if (NS_WARN_IF(!cmpToLimit)) { // Potentially handle this properly when Selection across Shadow DOM // boundary is implemented // (https://bugzilla.mozilla.org/show_bug.cgi?id=1607497). returnfalse;
}
if (!StaticPrefs::
layout_accessiblecaret_allow_dragging_across_other_caret()) { if ((mActiveCaret == mCarets.GetFirst() && *cmpToLimit == 1) ||
(mActiveCaret == mCarets.GetSecond() && *cmpToLimit == -1)) { // The active caret's position is past the limit, which we don't allow // here. So set it to the limit, resulting in one character being // selected.
SetOffsetsToLimit();
}
} else { switch (*cmpToInactiveCaretPos) { case 0: // The active caret's position is the same as the position of the // inactive caret. So set it to the limit to prevent the selection from // being collapsed, resulting in one character being selected.
SetOffsetsToLimit(); break; case 1: if (mActiveCaret == mCarets.GetFirst()) { // First caret was moved across the second caret. After making change // to the selection, the user will drag the second caret.
mActiveCaret = mCarets.GetSecond();
} break; case -1: if (mActiveCaret == mCarets.GetSecond()) { // Second caret was moved across the first caret. After making change // to the selection, the user will drag the first caret.
mActiveCaret = mCarets.GetFirst();
} break;
}
}
// Drill through scroll frames, we don't want to include scrollbar child // frames below. for (nsIFrame* frame = aFrame->GetContentInsertionFrame(); frame;
frame = frame->GetNextContinuation()) {
nsRect frameRect;
for (constauto& childList : frame->ChildLists()) { // Loop all children to union their scrollable overflow rect. for (nsIFrame* child : childList.mList) {
nsRect childRect = child->ScrollableOverflowRectRelativeToSelf();
nsLayoutUtils::TransformRect(child, frame, childRect);
// A TextFrame containing only '\n' has positive height and width 0, or // positive width and height 0 if it's vertical. Need to use UnionEdges // to add its rect. BRFrame rect should be non-empty. if (childRect.IsEmpty()) {
frameRect = frameRect.UnionEdges(childRect);
} else {
frameRect = frameRect.Union(childRect);
}
}
}
MOZ_ASSERT(!frameRect.IsEmpty(), "Editable frames should have at least one BRFrame child to make " "frameRect non-empty!"); if (frame != aFrame) {
nsLayoutUtils::TransformRect(frame, aFrame, frameRect);
}
unionRect = unionRect.Union(frameRect);
}
if (GetCaretMode() == CaretMode::Selection &&
!StaticPrefs::
layout_accessiblecaret_allow_dragging_across_other_caret()) { // Bug 1068474: Adjust the Y-coordinate so that the carets won't be in tilt // mode when a caret is being dragged surpass the other caret. // // For example, when dragging the second caret, the horizontal boundary // (lower bound) of its Y-coordinate is the logical position of the first // caret. Likewise, when dragging the first caret, the horizontal boundary // (upper bound) of its Y-coordinate is the logical position of the second // caret. if (mActiveCaret == mCarets.GetFirst()) {
nscoord dragDownBoundaryY = mCarets.GetSecond()->LogicalPosition().y; if (dragDownBoundaryY > 0 && adjustedPoint.y > dragDownBoundaryY) {
adjustedPoint.y = dragDownBoundaryY;
}
} else {
nscoord dragUpBoundaryY = mCarets.GetFirst()->LogicalPosition().y; if (adjustedPoint.y < dragUpBoundaryY) {
adjustedPoint.y = dragUpBoundaryY;
}
}
}
// Send isEditable info w/ event detail. This info can help determine // whether to show cut command on selection dialog or not.
init.mSelectionEditable = GetEditingHostForFrame(commonAncestorFrame);
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung ist noch experimentell.