RecipientEditTextView.java revision fd68ea64d80f3b299b20ab72b6ed71d3c4ff6c9c
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.ex.chips;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.Canvas;
22import android.graphics.Paint;
23import android.graphics.Rect;
24import android.graphics.drawable.BitmapDrawable;
25import android.graphics.drawable.Drawable;
26import android.os.Handler;
27import android.text.Editable;
28import android.text.Layout;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.Spanned;
32import android.text.TextPaint;
33import android.text.TextUtils;
34import android.text.method.QwertyKeyListener;
35import android.text.style.ImageSpan;
36import android.util.AttributeSet;
37import android.util.Log;
38import android.view.ActionMode;
39import android.view.KeyEvent;
40import android.view.Menu;
41import android.view.MenuItem;
42import android.view.MotionEvent;
43import android.view.View;
44import android.view.ActionMode.Callback;
45import android.widget.AdapterView;
46import android.widget.AdapterView.OnItemClickListener;
47import android.widget.ListPopupWindow;
48import android.widget.MultiAutoCompleteTextView;
49import android.widget.PopupWindow.OnDismissListener;
50
51import java.util.Collection;
52import java.util.HashSet;
53import java.util.Set;
54
55import java.util.ArrayList;
56
57/**
58 * RecipientEditTextView is an auto complete text view for use with applications
59 * that use the new Chips UI for addressing a message to recipients.
60 */
61public class RecipientEditTextView extends MultiAutoCompleteTextView
62    implements OnItemClickListener, Callback {
63
64    private static final String TAG = "RecipientEditTextView";
65
66    private Drawable mChipBackground = null;
67
68    private Drawable mChipDelete = null;
69
70    private int mChipPadding;
71
72    private Tokenizer mTokenizer;
73
74    private final Handler mHandler;
75
76    private Runnable mDelayedSelectionMode = new Runnable() {
77        @Override
78        public void run() {
79            setSelection(getText().length());
80        }
81    };
82
83    private Drawable mChipBackgroundPressed;
84
85    private RecipientChip mSelectedChip;
86
87    private int mChipDeleteWidth;
88
89    private ArrayList<RecipientChip> mRecipients;
90
91    private int mAlternatesLayout;
92
93    private int mAlternatesSelectedLayout;
94
95    public RecipientEditTextView(Context context, AttributeSet attrs) {
96        super(context, attrs);
97        mHandler = new Handler();
98        setOnItemClickListener(this);
99        mRecipients = new ArrayList<RecipientChip>();
100        setCustomSelectionActionModeCallback(this);
101    }
102
103    @Override
104    public void onSelectionChanged(int start, int end) {
105        // When selection changes, see if it is inside the chips area.
106        // If so, move the cursor back after the chips again.
107        if (mRecipients != null && mRecipients.size() > 0) {
108            Spannable span = getSpannable();
109            RecipientChip[] chips = span.getSpans(start, getText().length(), RecipientChip.class);
110            if (chips != null && chips.length > 0) {
111                // Grab the last chip and set the cursor to after it.
112                setSelection(chips[chips.length - 1].getChipEnd() + 1);
113            }
114        } else {
115            super.onSelectionChanged(start, end);
116        }
117    }
118
119    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
120        throws NullPointerException {
121        if (mChipBackground == null) {
122            throw new NullPointerException
123                ("Unable to render any chips as setChipDimensions was not called.");
124        }
125        String text = contact.getDisplayName();
126        Layout layout = getLayout();
127        int line = layout.getLineForOffset(offset);
128        int lineTop = layout.getLineTop(line);
129
130        TextPaint paint = getPaint();
131        float defaultSize = paint.getTextSize();
132
133        // Reduce the size of the text slightly so that we can get the "look" of
134        // padding.
135        paint.setTextSize((float) (paint.getTextSize() * .9));
136
137        // Ellipsize the text so that it takes AT MOST the entire width of the
138        // autocomplete text entry area. Make sure to leave space for padding
139        // on the sides.
140        CharSequence ellipsizedText = TextUtils.ellipsize(text, paint,
141                calculateAvailableWidth(pressed), TextUtils.TruncateAt.END);
142
143        int height = getLineHeight();
144        int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length()))
145                + (mChipPadding * 2);
146        if (pressed) {
147            width += mChipDeleteWidth;
148        }
149
150        // Create the background of the chip.
151        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
152        Canvas canvas = new Canvas(tmpBitmap);
153        if (pressed) {
154            if (mChipBackgroundPressed != null) {
155                mChipBackgroundPressed.setBounds(0, 0, width, height);
156                mChipBackgroundPressed.draw(canvas);
157
158                // Align the display text with where the user enters text.
159                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
160                        - layout.getLineDescent(line), paint);
161                mChipDelete.setBounds(width - mChipDeleteWidth, 0, width, height);
162                mChipDelete.draw(canvas);
163            } else {
164                Log.w(TAG,
165                        "Unable to draw a background for the chips as it was never set");
166            }
167        } else {
168            if (mChipBackground != null) {
169                mChipBackground.setBounds(0, 0, width, height);
170                mChipBackground.draw(canvas);
171
172                // Align the display text with where the user enters text.
173                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
174                        - layout.getLineDescent(line), paint);
175            } else {
176                Log.w(TAG,
177                        "Unable to draw a background for the chips as it was never set");
178            }
179        }
180
181
182        // Get the location of the widget so we can properly offset
183        // the anchor for each chip.
184        int[] xy = new int[2];
185        getLocationOnScreen(xy);
186        // Pass the full text, un-ellipsized, to the chip.
187        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
188        result.setBounds(0, 0, width, height);
189        Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width,
190                calculateLineBottom(xy[1], line));
191        RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds);
192
193        // Return text to the original size.
194        paint.setTextSize(defaultSize);
195
196        return recipientChip;
197    }
198
199    // The bottom of the line the chip will be located on is calculated by 4 factors:
200    // 1) which line the chip appears on
201    // 2) the height of a line in the autocomplete view
202    // 3) padding built into the edit text view will move the bottom position
203    // 4) the position of the autocomplete view on the screen, taking into account
204    // that any top padding will move this down visually
205    private int calculateLineBottom(int yOffset, int line) {
206        int bottomPadding = 0;
207        if (line == getLineCount() - 1) {
208            bottomPadding += getPaddingBottom();
209        }
210        return ((line + 1) * getLineHeight()) + (yOffset + getPaddingTop()) + bottomPadding;
211    }
212
213    // Get the max amount of space a chip can take up. The formula takes into
214    // account the width of the EditTextView, any view padding, and padding
215    // that will be added to the chip.
216    private float calculateAvailableWidth(boolean pressed) {
217        int paddingRight = 0;
218        if (pressed) {
219            paddingRight = mChipDeleteWidth;
220        }
221        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2)
222                - paddingRight;
223    }
224
225    /**
226     * Set all chip dimensions and resources. This has to be done from the application
227     * as this is a static library.
228     * @param chipBackground drawable
229     * @param padding Padding around the text in a chip
230     * @param offset Offset between the chip and the dropdown of alternates
231     */
232    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
233            Drawable chipDelete, int alternatesLayout, int alternatesSelectedLayout, float padding) {
234        mChipBackground = chipBackground;
235        mChipBackgroundPressed = chipBackgroundPressed;
236        mChipDelete = chipDelete;
237        mChipDeleteWidth = chipDelete.getIntrinsicWidth();
238        mChipPadding = (int) padding;
239        mAlternatesLayout = alternatesLayout;
240        mAlternatesSelectedLayout = alternatesSelectedLayout;
241    }
242
243    @Override
244    public void setTokenizer(Tokenizer tokenizer) {
245        mTokenizer = tokenizer;
246        super.setTokenizer(mTokenizer);
247    }
248
249    // We want to handle replacing text in the onItemClickListener
250    // so we can get all the associated contact information including
251    // display text, address, and id.
252    @Override
253    protected void replaceText(CharSequence text) {
254        return;
255    }
256
257    @Override
258    public boolean onKeyUp(int keyCode, KeyEvent event) {
259        switch (keyCode) {
260            case KeyEvent.KEYCODE_ENTER:
261            case KeyEvent.KEYCODE_DPAD_CENTER:
262            case KeyEvent.KEYCODE_TAB:
263                if (event.hasNoModifiers()) {
264                    if (isPopupShowing()) {
265                        // choose the first entry.
266                        submitItemAtPosition(0);
267                        dismissDropDown();
268                        return true;
269                    } else {
270                        int end = getSelectionEnd();
271                        int start = mTokenizer.findTokenStart(getText(), end);
272                        String text = getText().toString().substring(start, end);
273                        clearComposingText();
274
275                        Editable editable = getText();
276                        RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
277                        QwertyKeyListener.markAsReplaced(editable, start, end, "");
278                        editable.replace(start, end, createChip(entry));
279                        dismissDropDown();
280                    }
281                }
282        }
283        return super.onKeyUp(keyCode, event);
284    }
285
286    public void onChipChanged() {
287        // Must be posted so that the previous span
288        // is correctly replaced with the previous selection points.
289        mHandler.post(mDelayedSelectionMode);
290    }
291
292    @Override
293    public boolean onKeyDown(int keyCode, KeyEvent event) {
294
295        if (mSelectedChip != null) {
296            mSelectedChip.onKeyDown(keyCode, event);
297        }
298
299        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
300            return true;
301        }
302
303        return super.onKeyDown(keyCode, event);
304    }
305
306    private Spannable getSpannable() {
307        return (Spannable) getText();
308    }
309
310    /**
311     * Instead of filtering on the entire contents of the edit box,
312     * this subclass method filters on the range from
313     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
314     * if the length of that range meets or exceeds {@link #getThreshold}
315     * and makes sure that the range is not already a Chip.
316     */
317    @Override
318    protected void performFiltering(CharSequence text, int keyCode) {
319        if (enoughToFilter()) {
320            int end = getSelectionEnd();
321            int start = mTokenizer.findTokenStart(text, end);
322            // If this is a RecipientChip, don't filter
323            // on its contents.
324            Spannable span = getSpannable();
325            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
326            if (chips != null && chips.length > 0) {
327                return;
328            }
329        }
330        super.performFiltering(text, keyCode);
331    }
332
333    private void clearSelectedChip() {
334        if (mSelectedChip != null) {
335            mSelectedChip.unselectChip();
336            mSelectedChip = null;
337        }
338    }
339
340    @Override
341    public boolean onTouchEvent(MotionEvent event) {
342        int action = event.getAction();
343        boolean handled = super.onTouchEvent(event);
344        boolean chipWasSelected = false;
345
346        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
347            float x = event.getX();
348            float y = event.getY();
349            int offset = putOffsetInRange(getOffsetForPosition(x, y));
350            RecipientChip currentChip = findChip(offset);
351            if (currentChip != null) {
352                if (action == MotionEvent.ACTION_UP) {
353                    if (mSelectedChip != null && mSelectedChip != currentChip) {
354                        clearSelectedChip();
355                        mSelectedChip = currentChip.selectChip();
356                    } else if (mSelectedChip == null) {
357                        mSelectedChip = currentChip.selectChip();
358                    } else {
359                        mSelectedChip.onClick(this, offset, x, y);
360                    }
361                }
362                chipWasSelected = true;
363            }
364        }
365        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
366            clearSelectedChip();
367        }
368        return handled;
369    }
370
371    // TODO: This algorithm will need a lot of tweaking after more people have used
372    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
373    // what comes before the finger.
374    private int putOffsetInRange(int o) {
375        int offset = o;
376        Editable text = getText();
377        int length = text.length();
378        // Remove whitespace from end to find "real end"
379        int realLength = length;
380        for (int i = length - 1; i >= 0; i--) {
381            if (text.charAt(i) == ' ') {
382                realLength--;
383            } else {
384                break;
385            }
386        }
387
388        // If the offset is beyond or at the end of the text,
389        // leave it alone.
390        if (offset >= realLength) {
391            return offset;
392        }
393        Editable editable = getText();
394        while (offset >= 0 && (findText(editable, offset) == -1 && findChip(offset) == null)) {
395            // Keep walking backward!
396            offset--;
397        }
398        return offset;
399    }
400
401    private int findText(Editable text, int offset) {
402        if (text.charAt(offset) != ' ') {
403            return offset;
404        }
405        return -1;
406    }
407
408    private RecipientChip findChip(int offset) {
409        RecipientChip[] chips = getSpannable().getSpans(0, offset, RecipientChip.class);
410        // Find the chip that contains this offset.
411        for (int i = 0; i < chips.length; i++) {
412            RecipientChip chip = chips[i];
413            if (chip.matchesChip(offset)) {
414                return chip;
415            }
416        }
417        return null;
418    }
419
420    private CharSequence createChip(RecipientEntry entry) {
421        CharSequence displayText = mTokenizer.terminateToken(entry.getDestination());
422        // Always leave a blank space at the end of a chip.
423        int textLength = displayText.length();
424        if (displayText.charAt(textLength - 1) == ' ') {
425            textLength--;
426        } else {
427            displayText = displayText.toString().concat(" ");
428            textLength = displayText.length();
429        }
430        SpannableString chipText = new SpannableString(displayText);
431        int end = getSelectionEnd();
432        int start = mTokenizer.findTokenStart(getText(), end);
433        try {
434            chipText.setSpan(constructChipSpan(entry, start, false), 0, textLength,
435                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
436        } catch (NullPointerException e) {
437            Log.e(TAG, e.getMessage(), e);
438            return null;
439        }
440
441        return chipText;
442    }
443
444    @Override
445    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
446        submitItemAtPosition(position);
447    }
448
449    private void submitItemAtPosition(int position) {
450        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
451        clearComposingText();
452
453        int end = getSelectionEnd();
454        int start = mTokenizer.findTokenStart(getText(), end);
455
456        Editable editable = getText();
457        editable.replace(start, end, createChip(entry));
458        QwertyKeyListener.markAsReplaced(editable, start, end, "");
459    }
460
461    /** Returns a collection of contact Id for each chip inside this View. */
462    /* package */ Collection<Long> getContactIds() {
463        final Set<Long> result = new HashSet<Long>();
464        for (RecipientChip chip : mRecipients) {
465            result.add(chip.getContactId());
466        }
467        return result;
468    }
469
470    /** Returns a collection of data Id for each chip inside this View. May be null. */
471    /* package */ Collection<Long> getDataIds() {
472        final Set<Long> result = new HashSet<Long>();
473        for (RecipientChip chip : mRecipients) {
474            result.add(chip.getDataId());
475        }
476        return result;
477    }
478
479    /**
480     * RecipientChip defines an ImageSpan that contains information relevant to
481     * a particular recipient.
482     */
483    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener {
484        private final CharSequence mDisplay;
485
486        private final CharSequence mValue;
487
488        private final int mOffset;
489
490        private ListPopupWindow mPopup;
491
492        private View mAnchorView;
493
494        private int mLeft;
495
496        private final long mContactId;
497
498        private final long mDataId;
499
500        private RecipientEntry mEntry;
501
502        private boolean mSelected = false;
503
504        private RecipientAlternatesAdapter mAlternatesAdapter;
505
506        private Rect mBounds;
507
508        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
509            super(drawable);
510            mDisplay = entry.getDisplayName();
511            mValue = entry.getDestination();
512            mContactId = entry.getContactId();
513            mDataId = entry.getDataId();
514            mOffset = offset;
515            mEntry = entry;
516            mBounds = bounds;
517
518            mAnchorView = new View(getContext());
519            mAnchorView.setLeft(bounds.left);
520            mAnchorView.setRight(bounds.left);
521            mAnchorView.setTop(bounds.bottom);
522            mAnchorView.setBottom(bounds.bottom);
523            mAnchorView.setVisibility(View.GONE);
524            mRecipients.add(this);
525        }
526
527        public void unselectChip() {
528            if (getChipStart() == -1 || getChipEnd() == -1) {
529                mSelectedChip = null;
530                return;
531            }
532            clearComposingText();
533            RecipientChip newChipSpan = null;
534            try {
535                newChipSpan = constructChipSpan(mEntry, mOffset, false);
536            } catch (NullPointerException e) {
537                Log.e(TAG, e.getMessage(), e);
538                return;
539            }
540            replace(newChipSpan);
541            if (mPopup != null && mPopup.isShowing()) {
542                mPopup.dismiss();
543            }
544            return;
545        }
546
547        public void onKeyDown(int keyCode, KeyEvent event) {
548            if (keyCode == KeyEvent.KEYCODE_DEL) {
549                if (mPopup != null && mPopup.isShowing()) {
550                    mPopup.dismiss();
551                }
552                removeChip();
553            }
554        }
555
556        public boolean isCompletedContact() {
557            return mContactId != -1;
558        }
559
560        private void replace(RecipientChip newChip) {
561            Spannable spannable = getSpannable();
562            int spanStart = getChipStart();
563            int spanEnd = getChipEnd();
564            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
565            spannable.removeSpan(this);
566            mRecipients.remove(this);
567            spannable.setSpan(newChip, spanStart, spanEnd, 0);
568        }
569
570        public void removeChip() {
571            Spannable spannable = getSpannable();
572            int spanStart = getChipStart();
573            int spanEnd = getChipEnd();
574            if (this == mSelectedChip) {
575                mSelectedChip = null;
576            }
577            Editable text = getText();
578            int toDelete = spanEnd;
579            // Always remove trailing spaces when removing a chip.
580            while (toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') {
581                toDelete++;
582            }
583            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
584            spannable.removeSpan(this);
585            mRecipients.remove(this);
586            spannable.setSpan(null, spanStart, spanEnd, 0);
587            text.delete(spanStart, toDelete);
588        }
589
590        public int getChipStart() {
591            return getSpannable().getSpanStart(this);
592        }
593
594        public int getChipEnd() {
595            return getSpannable().getSpanEnd(this);
596        }
597
598        public void replaceChip(RecipientEntry entry) {
599            clearComposingText();
600
601            RecipientChip newChipSpan = null;
602            try {
603                newChipSpan = constructChipSpan(entry, mOffset, false);
604            } catch (NullPointerException e) {
605                Log.e(TAG, e.getMessage(), e);
606                return;
607            }
608            replace(newChipSpan);
609            if (mPopup != null && mPopup.isShowing()) {
610                mPopup.dismiss();
611            }
612            onChipChanged();
613        }
614
615        public RecipientChip selectChip() {
616            clearComposingText();
617            RecipientChip newChipSpan = null;
618            if (isCompletedContact()) {
619                try {
620                    newChipSpan = constructChipSpan(mEntry, mOffset, true);
621                    newChipSpan.setSelected(true);
622                } catch (NullPointerException e) {
623                    Log.e(TAG, e.getMessage(), e);
624                    return newChipSpan;
625                }
626                replace(newChipSpan);
627                if (mPopup != null && mPopup.isShowing()) {
628                    mPopup.dismiss();
629                }
630                mSelected = true;
631                // Make sure we call edit on the new chip span.
632                newChipSpan.showAlternates();
633            } else {
634                CharSequence text = getValue();
635                removeChip();
636                Editable editable = getText();
637                setSelection(editable.length());
638                editable.append(text);
639            }
640            return newChipSpan;
641        }
642
643        private void showAlternates() {
644            mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext());
645
646            if (!mPopup.isShowing()) {
647                mAlternatesAdapter = new RecipientAlternatesAdapter(
648                        RecipientEditTextView.this.getContext(),
649                        mEntry.getContactId(), mEntry.getDataId(),
650                        mAlternatesLayout, mAlternatesSelectedLayout);
651                mAnchorView.setLeft(mLeft);
652                mAnchorView.setRight(mLeft);
653                mPopup.setAnchorView(mAnchorView);
654                mPopup.setAdapter(mAlternatesAdapter);
655                mPopup.setWidth(getWidth());
656                mPopup.setOnItemClickListener(this);
657                mPopup.setOnDismissListener(this);
658                mPopup.show();
659            }
660        }
661
662        private void setSelected(boolean selected) {
663            mSelected = selected;
664        }
665
666        public CharSequence getDisplay() {
667            return mDisplay;
668        }
669
670        public CharSequence getValue() {
671            return mValue;
672        }
673
674        private boolean isInDelete(int offset, float x, float y) {
675            // Figure out the bounds of this chip and whether or not
676            // the user clicked in the X portion.
677            return mSelected
678                    && (offset == getChipEnd()
679                            || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right));
680        }
681
682        public boolean matchesChip(int offset) {
683            int start = getChipStart();
684            int end = getChipEnd();
685            return (offset >= start && offset <= end);
686        }
687
688        public void onClick(View widget, int offset, float x, float y) {
689            if (mSelected) {
690                if (isInDelete(offset, x, y)) {
691                    removeChip();
692                    return;
693                }
694            }
695        }
696
697        @Override
698        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
699                int y, int bottom, Paint paint) {
700            // Shift the bounds of this span to where it is actually drawn on the screeen.
701            mLeft = (int) x;
702            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
703        }
704
705        @Override
706        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
707            mPopup.dismiss();
708            clearComposingText();
709            replaceChip(mAlternatesAdapter.getRecipientEntry(position));
710        }
711
712        // When the popup dialog is dismissed, return the cursor to the end.
713        @Override
714        public void onDismiss() {
715            mHandler.post(mDelayedSelectionMode);
716        }
717
718        public long getContactId() {
719            return mContactId;
720        }
721
722        public long getDataId() {
723            return mDataId;
724        }
725    }
726
727    @Override
728    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
729        return false;
730    }
731
732    @Override
733    public void onDestroyActionMode(ActionMode mode) {
734    }
735
736    @Override
737    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
738        return false;
739    }
740
741    // Prevent selection of chips.
742    @Override
743    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
744        return false;
745    }
746}
747
748