Merge "Revert "Make ellipsize retry if text doesn't fit""
This commit is contained in:
committed by
Android (Google) Code Review
commit
200ae37936
@@ -2059,11 +2059,9 @@ public abstract class Layout {
|
||||
|
||||
final String ellipsisString = TextUtils.getEllipsisString(method);
|
||||
final int ellipsisStringLen = ellipsisString.length();
|
||||
// Use the ellipsis string only if there are that at least as many characters to replace.
|
||||
final boolean useEllipsisString = ellipsisCount >= ellipsisStringLen;
|
||||
for (int i = 0; i < ellipsisCount; i++) {
|
||||
final char c;
|
||||
if (useEllipsisString && i < ellipsisStringLen) {
|
||||
if (i < ellipsisStringLen && ellipsisCount <= ellipsisStringLen) {
|
||||
c = ellipsisString.charAt(i);
|
||||
} else {
|
||||
c = TextUtils.ELLIPSIS_FILLER;
|
||||
|
||||
@@ -720,7 +720,7 @@ public class StaticLayout extends Layout {
|
||||
// TODO: Support more justification mode, e.g. letter spacing, stretching.
|
||||
b.mJustificationMode != Layout.JUSTIFICATION_MODE_NONE);
|
||||
if (mLeftIndents != null || mRightIndents != null) {
|
||||
// TODO(performance): it would be better to do this once per layout rather
|
||||
// TODO(raph) performance: it would be better to do this once per layout rather
|
||||
// than once per paragraph, but that would require a change to the native
|
||||
// interface.
|
||||
int leftLen = mLeftIndents == null ? 0 : mLeftIndents.length;
|
||||
@@ -807,7 +807,7 @@ public class StaticLayout extends Layout {
|
||||
width += widths[j];
|
||||
}
|
||||
}
|
||||
flag |= flags[i] & TAB_MASK; // XXX May need to also have starting hyphen edit
|
||||
flag |= flags[i] & TAB_MASK;
|
||||
}
|
||||
// Treat the last line and overflowed lines as a single line.
|
||||
breaks[remainingLineCount - 1] = breaks[breakCount - 1];
|
||||
@@ -961,25 +961,6 @@ public class StaticLayout extends Layout {
|
||||
bottom = fm.bottom;
|
||||
}
|
||||
|
||||
// Information about hyphenation, tabs, and directions are needed for determining
|
||||
// ellipsization, so the values should be assigned before ellipsization.
|
||||
|
||||
// TODO: could move TAB to share same column as HYPHEN, simplifying this code and gaining
|
||||
// one bit for start field
|
||||
lines[off + TAB] = flags & TAB_MASK;
|
||||
lines[off + HYPHEN] = flags;
|
||||
|
||||
lines[off + DIR] |= dir << DIR_SHIFT;
|
||||
// easy means all chars < the first RTL, so no emoji, no nothing
|
||||
// XXX a run with no text or all spaces is easy but might be an empty
|
||||
// RTL paragraph. Make sure easy is false if this is the case.
|
||||
if (easy) {
|
||||
mLineDirections[j] = DIRS_ALL_LEFT_TO_RIGHT;
|
||||
} else {
|
||||
mLineDirections[j] = AndroidBidi.directions(dir, chdirs, start - widthStart, chs,
|
||||
start - widthStart, end - start);
|
||||
}
|
||||
|
||||
boolean firstLine = (j == 0);
|
||||
boolean currentLineIsTheLastVisibleOne = (j + 1 == mMaximumVisibleLineCount);
|
||||
|
||||
@@ -995,8 +976,8 @@ public class StaticLayout extends Layout {
|
||||
ellipsize == TextUtils.TruncateAt.END);
|
||||
if (doEllipsis) {
|
||||
calculateEllipsis(start, end, widths, widthStart,
|
||||
ellipsisWidth - getTotalInsets(j), ellipsize, j,
|
||||
textWidth, paint, forceEllipsis, dir);
|
||||
ellipsisWidth, ellipsize, j,
|
||||
textWidth, paint, forceEllipsis);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,7 +1029,7 @@ public class StaticLayout extends Layout {
|
||||
extra = 0;
|
||||
}
|
||||
|
||||
lines[off + START] |= start;
|
||||
lines[off + START] = start;
|
||||
lines[off + TOP] = v;
|
||||
lines[off + DESCENT] = below + extra;
|
||||
lines[off + EXTRA] = extra;
|
||||
@@ -1066,13 +1047,33 @@ public class StaticLayout extends Layout {
|
||||
lines[off + mColumns + START] = end;
|
||||
lines[off + mColumns + TOP] = v;
|
||||
|
||||
// TODO: could move TAB to share same column as HYPHEN, simplifying this code and gaining
|
||||
// one bit for start field
|
||||
lines[off + TAB] |= flags & TAB_MASK;
|
||||
lines[off + HYPHEN] = flags;
|
||||
|
||||
lines[off + DIR] |= dir << DIR_SHIFT;
|
||||
Directions linedirs = DIRS_ALL_LEFT_TO_RIGHT;
|
||||
// easy means all chars < the first RTL, so no emoji, no nothing
|
||||
// XXX a run with no text or all spaces is easy but might be an empty
|
||||
// RTL paragraph. Make sure easy is false if this is the case.
|
||||
if (easy) {
|
||||
mLineDirections[j] = linedirs;
|
||||
} else {
|
||||
mLineDirections[j] = AndroidBidi.directions(dir, chdirs, start - widthStart, chs,
|
||||
start - widthStart, end - start);
|
||||
}
|
||||
|
||||
mLineCount++;
|
||||
return v;
|
||||
}
|
||||
|
||||
private void calculateEllipsis(int lineStart, int lineEnd, float[] widths, int widthStart,
|
||||
float avail, TextUtils.TruncateAt where, int line, float textWidth, TextPaint paint,
|
||||
boolean forceEllipsis, int dir) {
|
||||
private void calculateEllipsis(int lineStart, int lineEnd,
|
||||
float[] widths, int widthStart,
|
||||
float avail, TextUtils.TruncateAt where,
|
||||
int line, float textWidth, TextPaint paint,
|
||||
boolean forceEllipsis) {
|
||||
avail -= getTotalInsets(line);
|
||||
if (textWidth <= avail && !forceEllipsis) {
|
||||
// Everything fits!
|
||||
mLines[mColumns * line + ELLIPSIS_START] = 0;
|
||||
@@ -1080,35 +1081,11 @@ public class StaticLayout extends Layout {
|
||||
return;
|
||||
}
|
||||
|
||||
float tempAvail = avail;
|
||||
while (true) {
|
||||
float ellipsizedWidth = guessEllipsis(lineStart, lineEnd, widths, widthStart, tempAvail,
|
||||
where, line, textWidth, paint, forceEllipsis, dir);
|
||||
if (ellipsizedWidth <= avail) {
|
||||
mEllipsized = true;
|
||||
return; // The ellipsized line fits.
|
||||
} else {
|
||||
// Some side effect of ellipsization has caused the text to go over the available
|
||||
// width. Let's make the available width shorter by exactly that amount and retry.
|
||||
tempAvail -= ellipsizedWidth - avail;
|
||||
}
|
||||
}
|
||||
}
|
||||
float ellipsisWidth = paint.measureText(TextUtils.getEllipsisString(where));
|
||||
int ellipsisStart = 0;
|
||||
int ellipsisCount = 0;
|
||||
int len = lineEnd - lineStart;
|
||||
|
||||
// Returns the width of the ellipsized line which in some rare cases can actually be larger
|
||||
// than 'avail' (due to kerning or other context-based effect of replacement of text by
|
||||
// ellipsis). If all the line needs to ellipsized away, or it's an invalud hyphenation mode,
|
||||
// returns 0 so the caller can stop iterating.
|
||||
private float guessEllipsis(int lineStart, int lineEnd, float[] widths, int widthStart,
|
||||
float avail, TextUtils.TruncateAt where, int line, float textWidth, TextPaint paint,
|
||||
boolean forceEllipsis, int dir) {
|
||||
final float ellipsisWidth = paint.measureText(TextUtils.getEllipsisString(where));
|
||||
final int ellipsisStart;
|
||||
final int ellipsisCount;
|
||||
final int len = lineEnd - lineStart;
|
||||
final int offset = lineStart - widthStart;
|
||||
|
||||
int hyphen = getHyphen(line);
|
||||
// We only support start ellipsis on a single line
|
||||
if (where == TextUtils.TruncateAt.START) {
|
||||
if (mMaximumVisibleLineCount == 1) {
|
||||
@@ -1116,9 +1093,9 @@ public class StaticLayout extends Layout {
|
||||
int i;
|
||||
|
||||
for (i = len; i > 0; i--) {
|
||||
final float w = widths[i - 1 + offset];
|
||||
float w = widths[i - 1 + lineStart - widthStart];
|
||||
if (w + sum + ellipsisWidth > avail) {
|
||||
while (i < len && widths[i + offset] == 0.0f) {
|
||||
while (i < len && widths[i + lineStart - widthStart] == 0.0f) {
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
@@ -1129,13 +1106,9 @@ public class StaticLayout extends Layout {
|
||||
|
||||
ellipsisStart = 0;
|
||||
ellipsisCount = i;
|
||||
// Strip the potential hyphenation at beginning of line.
|
||||
hyphen &= ~Paint.HYPHENEDIT_MASK_START_OF_LINE;
|
||||
} else {
|
||||
ellipsisStart = 0;
|
||||
ellipsisCount = 0;
|
||||
if (Log.isLoggable(TAG, Log.WARN)) {
|
||||
Log.w(TAG, "Start ellipsis only supported with one line");
|
||||
Log.w(TAG, "Start Ellipsis only supported with one line");
|
||||
}
|
||||
}
|
||||
} else if (where == TextUtils.TruncateAt.END || where == TextUtils.TruncateAt.MARQUEE ||
|
||||
@@ -1144,7 +1117,7 @@ public class StaticLayout extends Layout {
|
||||
int i;
|
||||
|
||||
for (i = 0; i < len; i++) {
|
||||
final float w = widths[i + offset];
|
||||
float w = widths[i + lineStart - widthStart];
|
||||
|
||||
if (w + sum + ellipsisWidth > avail) {
|
||||
break;
|
||||
@@ -1153,27 +1126,24 @@ public class StaticLayout extends Layout {
|
||||
sum += w;
|
||||
}
|
||||
|
||||
if (forceEllipsis && i == len && len > 0) {
|
||||
ellipsisStart = i;
|
||||
ellipsisCount = len - i;
|
||||
if (forceEllipsis && ellipsisCount == 0 && len > 0) {
|
||||
ellipsisStart = len - 1;
|
||||
ellipsisCount = 1;
|
||||
} else {
|
||||
ellipsisStart = i;
|
||||
ellipsisCount = len - i;
|
||||
}
|
||||
// Strip the potential hyphenation at end of line.
|
||||
hyphen &= ~Paint.HYPHENEDIT_MASK_END_OF_LINE;
|
||||
} else { // where = TextUtils.TruncateAt.MIDDLE
|
||||
// We only support middle ellipsis on a single line.
|
||||
} else {
|
||||
// where = TextUtils.TruncateAt.MIDDLE We only support middle ellipsis on a single line
|
||||
if (mMaximumVisibleLineCount == 1) {
|
||||
float lsum = 0, rsum = 0;
|
||||
int left = 0, right = len;
|
||||
|
||||
final float ravail = (avail - ellipsisWidth) / 2;
|
||||
float ravail = (avail - ellipsisWidth) / 2;
|
||||
for (right = len; right > 0; right--) {
|
||||
final float w = widths[right - 1 + offset];
|
||||
float w = widths[right - 1 + lineStart - widthStart];
|
||||
|
||||
if (w + rsum > ravail) {
|
||||
while (right < len && widths[right + offset] == 0.0f) {
|
||||
while (right < len && widths[right + lineStart - widthStart] == 0.0f) {
|
||||
right++;
|
||||
}
|
||||
break;
|
||||
@@ -1181,9 +1151,9 @@ public class StaticLayout extends Layout {
|
||||
rsum += w;
|
||||
}
|
||||
|
||||
final float lavail = avail - ellipsisWidth - rsum;
|
||||
float lavail = avail - ellipsisWidth - rsum;
|
||||
for (left = 0; left < right; left++) {
|
||||
final float w = widths[left + offset];
|
||||
float w = widths[left + lineStart - widthStart];
|
||||
|
||||
if (w + lsum > lavail) {
|
||||
break;
|
||||
@@ -1195,46 +1165,14 @@ public class StaticLayout extends Layout {
|
||||
ellipsisStart = left;
|
||||
ellipsisCount = right - left;
|
||||
} else {
|
||||
ellipsisStart = 0;
|
||||
ellipsisCount = 0;
|
||||
if (Log.isLoggable(TAG, Log.WARN)) {
|
||||
Log.w(TAG, "Middle ellipsis only supported with one line");
|
||||
Log.w(TAG, "Middle Ellipsis only supported with one line");
|
||||
}
|
||||
}
|
||||
}
|
||||
mEllipsized = true;
|
||||
mLines[mColumns * line + ELLIPSIS_START] = ellipsisStart;
|
||||
mLines[mColumns * line + ELLIPSIS_COUNT] = ellipsisCount;
|
||||
|
||||
if (ellipsisStart == 0 && (ellipsisCount == 0 || ellipsisCount == len)) {
|
||||
// Unsupported ellipsization mode or all text is ellipsized away. Return 0.
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
final CharSequence text = getText();
|
||||
final boolean hasTabs = getLineContainsTab(line);
|
||||
final TabStops tabStops;
|
||||
if (hasTabs && text instanceof Spanned) {
|
||||
final TabStopSpan[] tabs = getParagraphSpans((Spanned) text, lineStart, lineEnd,
|
||||
TabStopSpan.class);
|
||||
if (tabs.length == 0) {
|
||||
tabStops = null;
|
||||
} else {
|
||||
tabStops = new TabStops(TAB_INCREMENT, tabs);
|
||||
}
|
||||
} else {
|
||||
tabStops = null;
|
||||
}
|
||||
final TextLine textline = TextLine.obtain();
|
||||
paint.setHyphenEdit(hyphen);
|
||||
textline.set(paint,
|
||||
text, /* this is an instance of Ellipsizer or SpannedEllipsizer */
|
||||
lineStart, lineEnd,
|
||||
dir, getLineDirections(line),
|
||||
hasTabs, tabStops);
|
||||
final float hyphenatedWidth = textline.metrics(null);
|
||||
paint.setHyphenEdit(0);
|
||||
TextLine.recycle(textline);
|
||||
return hyphenatedWidth;
|
||||
}
|
||||
|
||||
private float getTotalInsets(int line) {
|
||||
|
||||
@@ -89,8 +89,8 @@ public class TextUtils {
|
||||
|
||||
/** {@hide} */
|
||||
@NonNull
|
||||
public static String getEllipsisString(@NonNull TruncateAt method) {
|
||||
return (method == TruncateAt.END_SMALL) ? ELLIPSIS_TWO_DOTS : ELLIPSIS_NORMAL;
|
||||
public static String getEllipsisString(@NonNull TextUtils.TruncateAt method) {
|
||||
return (method == TextUtils.TruncateAt.END_SMALL) ? ELLIPSIS_TWO_DOTS : ELLIPSIS_NORMAL;
|
||||
}
|
||||
|
||||
|
||||
@@ -1187,11 +1187,9 @@ public class TextUtils {
|
||||
* or, if it does not fit, a truncated
|
||||
* copy with ellipsis character added at the specified edge or center.
|
||||
*/
|
||||
@NonNull
|
||||
public static CharSequence ellipsize(@NonNull CharSequence text,
|
||||
@NonNull TextPaint p,
|
||||
@FloatRange(from = 0.0) float avail,
|
||||
@NonNull TruncateAt where) {
|
||||
public static CharSequence ellipsize(CharSequence text,
|
||||
TextPaint p,
|
||||
float avail, TruncateAt where) {
|
||||
return ellipsize(text, p, avail, where, false, null);
|
||||
}
|
||||
|
||||
@@ -1207,11 +1205,9 @@ public class TextUtils {
|
||||
* report the start and end of the ellipsized range. TextDirection
|
||||
* is determined by the first strong directional character.
|
||||
*/
|
||||
@NonNull
|
||||
public static CharSequence ellipsize(@NonNull CharSequence text,
|
||||
@NonNull TextPaint paint,
|
||||
@FloatRange(from = 0.0) float avail,
|
||||
@NonNull TruncateAt where,
|
||||
public static CharSequence ellipsize(CharSequence text,
|
||||
TextPaint paint,
|
||||
float avail, TruncateAt where,
|
||||
boolean preserveLength,
|
||||
@Nullable EllipsizeCallback callback) {
|
||||
return ellipsize(text, paint, avail, where, preserveLength, callback,
|
||||
@@ -1232,19 +1228,16 @@ public class TextUtils {
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
@NonNull
|
||||
public static CharSequence ellipsize(@NonNull CharSequence text,
|
||||
@NonNull TextPaint paint,
|
||||
@FloatRange(from = 0.0) float avail,
|
||||
@NonNull TruncateAt where,
|
||||
public static CharSequence ellipsize(CharSequence text,
|
||||
TextPaint paint,
|
||||
float avail, TruncateAt where,
|
||||
boolean preserveLength,
|
||||
@Nullable EllipsizeCallback callback,
|
||||
@NonNull TextDirectionHeuristic textDir,
|
||||
@NonNull String ellipsis) {
|
||||
TextDirectionHeuristic textDir, String ellipsis) {
|
||||
|
||||
final int len = text.length();
|
||||
final MeasuredText mt = MeasuredText.obtain();
|
||||
MeasuredText resultMt = null;
|
||||
int len = text.length();
|
||||
|
||||
MeasuredText mt = MeasuredText.obtain();
|
||||
try {
|
||||
float width = setPara(mt, paint, text, 0, text.length(), textDir);
|
||||
|
||||
@@ -1252,88 +1245,74 @@ public class TextUtils {
|
||||
if (callback != null) {
|
||||
callback.ellipsized(0, 0);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
resultMt = MeasuredText.obtain();
|
||||
// First estimate of effective width of ellipsis.
|
||||
float ellipsisWidth = paint.measureText(ellipsis);
|
||||
while (true) { // Repeat until the ellipsized text fits.
|
||||
final float remainingWidth = avail - ellipsisWidth;
|
||||
final int start, end;
|
||||
if (remainingWidth < 0) {
|
||||
// Even the ellipsis can't fit. So it all goes.
|
||||
start = 0;
|
||||
end = len;
|
||||
} else if (where == TruncateAt.START) {
|
||||
start = 0;
|
||||
end = len - mt.breakText(len, false /* backwards */, remainingWidth);
|
||||
} else if (where == TruncateAt.END || where == TruncateAt.END_SMALL) {
|
||||
start = mt.breakText(len, true /* forwards */, remainingWidth);
|
||||
end = len;
|
||||
} else {
|
||||
end = len - mt.breakText(len, false /* backwards */, remainingWidth / 2);
|
||||
start = mt.breakText(end, true /* forwards */,
|
||||
remainingWidth - mt.measure(end, len));
|
||||
}
|
||||
// XXX assumes ellipsis string does not require shaping and
|
||||
// is unaffected by style
|
||||
float ellipsiswid = paint.measureText(ellipsis);
|
||||
avail -= ellipsiswid;
|
||||
|
||||
final char[] buf = mt.mChars;
|
||||
final Spanned sp = text instanceof Spanned ? (Spanned) text : null;
|
||||
|
||||
final CharSequence result;
|
||||
final int removed = end - start;
|
||||
final int remaining = len - removed;
|
||||
if (preserveLength) {
|
||||
int pos = start;
|
||||
if (remaining > 0 && removed >= ellipsis.length()) {
|
||||
ellipsis.getChars(0, ellipsis.length(), buf, start);
|
||||
pos += ellipsis.length();
|
||||
} // else eliminate the ellipsis
|
||||
while (pos < end) {
|
||||
buf[pos++] = ELLIPSIS_FILLER;
|
||||
}
|
||||
final String s = new String(buf, 0, len);
|
||||
if (sp == null) {
|
||||
result = s;
|
||||
} else {
|
||||
final SpannableString ss = new SpannableString(s);
|
||||
copySpansFrom(sp, 0, len, Object.class, ss, 0);
|
||||
result = ss;
|
||||
}
|
||||
} else {
|
||||
if (remaining == 0) {
|
||||
result = "";
|
||||
} else if (sp == null) {
|
||||
final StringBuilder sb = new StringBuilder(remaining + ellipsis.length());
|
||||
sb.append(buf, 0, start);
|
||||
sb.append(ellipsis);
|
||||
sb.append(buf, end, len - end);
|
||||
result = sb.toString();
|
||||
} else {
|
||||
final SpannableStringBuilder ssb = new SpannableStringBuilder();
|
||||
ssb.append(text, 0, start);
|
||||
ssb.append(ellipsis);
|
||||
ssb.append(text, end, len);
|
||||
result = ssb;
|
||||
}
|
||||
}
|
||||
|
||||
width = setPara(resultMt, paint, result, 0, result.length(), textDir);
|
||||
if (width <= avail || remaining == 0) { // Text fits or all text is gone.
|
||||
if (callback != null) {
|
||||
callback.ellipsized(start, end);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// Adjust the width of the ellipsis by adding the amount 'width' is still over.
|
||||
ellipsisWidth += width - avail;
|
||||
}
|
||||
int left = 0;
|
||||
int right = len;
|
||||
if (avail < 0) {
|
||||
// it all goes
|
||||
} else if (where == TruncateAt.START) {
|
||||
right = len - mt.breakText(len, false, avail);
|
||||
} else if (where == TruncateAt.END || where == TruncateAt.END_SMALL) {
|
||||
left = mt.breakText(len, true, avail);
|
||||
} else {
|
||||
right = len - mt.breakText(len, false, avail / 2);
|
||||
avail -= mt.measure(right, len);
|
||||
left = mt.breakText(right, true, avail);
|
||||
}
|
||||
|
||||
if (callback != null) {
|
||||
callback.ellipsized(left, right);
|
||||
}
|
||||
|
||||
char[] buf = mt.mChars;
|
||||
Spanned sp = text instanceof Spanned ? (Spanned) text : null;
|
||||
|
||||
final int removed = right - left;
|
||||
final int remaining = len - removed;
|
||||
if (preserveLength) {
|
||||
if (remaining > 0 && removed >= ellipsis.length()) {
|
||||
ellipsis.getChars(0, ellipsis.length(), buf, left);
|
||||
left += ellipsis.length();
|
||||
} // else skip the ellipsis
|
||||
for (int i = left; i < right; i++) {
|
||||
buf[i] = ELLIPSIS_FILLER;
|
||||
}
|
||||
String s = new String(buf, 0, len);
|
||||
if (sp == null) {
|
||||
return s;
|
||||
}
|
||||
SpannableString ss = new SpannableString(s);
|
||||
copySpansFrom(sp, 0, len, Object.class, ss, 0);
|
||||
return ss;
|
||||
}
|
||||
|
||||
if (remaining == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (sp == null) {
|
||||
StringBuilder sb = new StringBuilder(remaining + ellipsis.length());
|
||||
sb.append(buf, 0, left);
|
||||
sb.append(ellipsis);
|
||||
sb.append(buf, right, len - right);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
SpannableStringBuilder ssb = new SpannableStringBuilder();
|
||||
ssb.append(text, 0, left);
|
||||
ssb.append(ellipsis);
|
||||
ssb.append(text, right, len);
|
||||
return ssb;
|
||||
} finally {
|
||||
MeasuredText.recycle(mt);
|
||||
if (resultMt != null) {
|
||||
MeasuredText.recycle(resultMt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,6 +1343,7 @@ public class TextUtils {
|
||||
* @return the formatted CharSequence. If even the shortest sequence (e.g. {@code "A, 11 more"})
|
||||
* doesn't fit, it will return an empty string.
|
||||
*/
|
||||
|
||||
public static CharSequence listEllipsize(@Nullable Context context,
|
||||
@Nullable List<CharSequence> elements, @NonNull String separator,
|
||||
@NonNull TextPaint paint, @FloatRange(from=0.0,fromInclusive=false) float avail,
|
||||
|
||||
Reference in New Issue
Block a user