Merge remote-tracking branch 'origin/master' into 1994_buffer_api
This commit is contained in:
@@ -10,6 +10,7 @@ import BaseCharAtlas from './atlas/BaseCharAtlas';
|
||||
import { acquireCharAtlas } from './atlas/CharAtlasCache';
|
||||
import { CellData, AttributeData } from '../BufferLine';
|
||||
import { WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from '../Buffer';
|
||||
import { JoinedCellData } from './CharacterJoinerRegistry';
|
||||
|
||||
export abstract class BaseRenderLayer implements IRenderLayer {
|
||||
private _canvas: HTMLCanvasElement;
|
||||
@@ -261,7 +262,10 @@ export abstract class BaseRenderLayer implements IRenderLayer {
|
||||
protected drawChars(terminal: ITerminal, cell: ICellData, x: number, y: number): void {
|
||||
|
||||
// skip cache right away if we draw in RGB
|
||||
if (cell.isFgRGB() || cell.isBgRGB()) {
|
||||
// Note: to avoid bad runtime JoinedCellData will be skipped
|
||||
// in the cache handler (atlasDidDraw == false) itself and
|
||||
// fall through to uncached later down below
|
||||
if (cell.isFgRGB() || cell.isBgRGB() || cell instanceof JoinedCellData) {
|
||||
this._drawUncachedChars(terminal, cell, x, y);
|
||||
return;
|
||||
}
|
||||
@@ -322,14 +326,18 @@ export abstract class BaseRenderLayer implements IRenderLayer {
|
||||
} else {
|
||||
this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css;
|
||||
}
|
||||
} else if (cell.isFgRGB()) {
|
||||
this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
|
||||
} else if (cell.isFgPalette()) {
|
||||
let fg = cell.getFgColor();
|
||||
if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) {
|
||||
fg += 8;
|
||||
} else {
|
||||
if (cell.isFgDefault()) {
|
||||
this._ctx.fillStyle = this._colors.foreground.css;
|
||||
} else if (cell.isFgRGB()) {
|
||||
this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
|
||||
} else {
|
||||
let fg = cell.getFgColor();
|
||||
if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) {
|
||||
fg += 8;
|
||||
}
|
||||
this._ctx.fillStyle = this._colors.ansi[fg].css;
|
||||
}
|
||||
this._ctx.fillStyle = this._colors.ansi[fg].css;
|
||||
}
|
||||
|
||||
this._clipRow(terminal, y);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { MockTerminal, MockBuffer } from '../ui/TestUtils.test';
|
||||
import { MockTerminal, MockBuffer } from '../TestUtils.test';
|
||||
import { CircularList } from '../common/CircularList';
|
||||
|
||||
import { ICharacterJoinerRegistry } from './Types';
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import { ITerminal, IBufferLine } from '../Types';
|
||||
import { ITerminal, IBufferLine, ICellData, CharData } from '../Types';
|
||||
import { ICharacterJoinerRegistry, ICharacterJoiner } from './Types';
|
||||
import { CellData } from '../BufferLine';
|
||||
import { CellData, Content, AttributeData } from '../BufferLine';
|
||||
import { WHITESPACE_CELL_CHAR } from '../Buffer';
|
||||
|
||||
export class JoinedCellData extends AttributeData implements ICellData {
|
||||
private _width: number;
|
||||
// .content carries no meaning for joined CellData, simply nullify it
|
||||
// thus we have to overload all other .content accessors
|
||||
public content: number = 0;
|
||||
public fg: number;
|
||||
public bg: number;
|
||||
public combinedData: string = '';
|
||||
|
||||
constructor(firstCell: ICellData, chars: string, width: number) {
|
||||
super();
|
||||
this.fg = firstCell.fg;
|
||||
this.bg = firstCell.bg;
|
||||
this.combinedData = chars;
|
||||
this._width = width;
|
||||
}
|
||||
|
||||
public isCombined(): number {
|
||||
// always mark joined cell data as combined
|
||||
return Content.IS_COMBINED_MASK;
|
||||
}
|
||||
|
||||
public getWidth(): number {
|
||||
return this._width;
|
||||
}
|
||||
|
||||
public getChars(): string {
|
||||
return this.combinedData;
|
||||
}
|
||||
|
||||
public getCode(): number {
|
||||
// code always gets the highest possible fake codepoint (read as -1)
|
||||
// this is needed as code is used by caches as identifier
|
||||
return 0x1FFFFF;
|
||||
}
|
||||
|
||||
public setFromCharData(value: CharData): void {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
public getAsCharData(): CharData {
|
||||
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
|
||||
}
|
||||
}
|
||||
|
||||
export class CharacterJoinerRegistry implements ICharacterJoinerRegistry {
|
||||
|
||||
private _characterJoiners: ICharacterJoiner[] = [];
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ILinkHoverEvent, ITerminal, ILinkifierAccessor, LinkHoverEventTypes } from '../Types';
|
||||
import { ILinkifierEvent, ITerminal, ILinkifierAccessor } from '../Types';
|
||||
import { IColorSet, IRenderDimensions } from './Types';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
import { INVERTED_DEFAULT_COLOR } from './atlas/Types';
|
||||
import { is256Color } from './atlas/CharAtlasUtils';
|
||||
|
||||
export class LinkRenderLayer extends BaseRenderLayer {
|
||||
private _state: ILinkHoverEvent = null;
|
||||
private _state: ILinkifierEvent = null;
|
||||
|
||||
constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) {
|
||||
super(container, 'link', zIndex, true, colors);
|
||||
terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: ILinkHoverEvent) => this._onLinkHover(e));
|
||||
terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: ILinkHoverEvent) => this._onLinkLeave(e));
|
||||
terminal.linkifier.onLinkHover(e => this._onLinkHover(e));
|
||||
terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e));
|
||||
}
|
||||
|
||||
public resize(terminal: ITerminal, dim: IRenderDimensions): void {
|
||||
@@ -40,7 +40,7 @@ export class LinkRenderLayer extends BaseRenderLayer {
|
||||
}
|
||||
}
|
||||
|
||||
private _onLinkHover(e: ILinkHoverEvent): void {
|
||||
private _onLinkHover(e: ILinkifierEvent): void {
|
||||
if (e.fg === INVERTED_DEFAULT_COLOR) {
|
||||
this._ctx.fillStyle = this._colors.background.css;
|
||||
} else if (is256Color(e.fg)) {
|
||||
@@ -64,7 +64,7 @@ export class LinkRenderLayer extends BaseRenderLayer {
|
||||
this._state = e;
|
||||
}
|
||||
|
||||
private _onLinkLeave(e: ILinkHoverEvent): void {
|
||||
private _onLinkLeave(e: ILinkifierEvent): void {
|
||||
this._clearCurrentLink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import { ColorManager } from './ColorManager';
|
||||
import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions, ICharacterJoinerRegistry } from './Types';
|
||||
import { ITerminal, CharacterJoinerHandler } from '../Types';
|
||||
import { LinkRenderLayer } from './LinkRenderLayer';
|
||||
import { EventEmitter } from '../common/EventEmitter';
|
||||
import { RenderDebouncer } from '../ui/RenderDebouncer';
|
||||
import { ScreenDprMonitor } from '../ui/ScreenDprMonitor';
|
||||
import { ITheme } from 'xterm';
|
||||
import { CharacterJoinerRegistry } from '../renderer/CharacterJoinerRegistry';
|
||||
import { EventEmitter2, IEvent } from '../common/EventEmitter2';
|
||||
import { Disposable } from '../common/Lifecycle';
|
||||
|
||||
export class Renderer extends EventEmitter implements IRenderer {
|
||||
export class Renderer extends Disposable implements IRenderer {
|
||||
private _renderDebouncer: RenderDebouncer;
|
||||
|
||||
private _renderLayers: IRenderLayer[];
|
||||
@@ -29,6 +30,11 @@ export class Renderer extends EventEmitter implements IRenderer {
|
||||
public colorManager: ColorManager;
|
||||
public dimensions: IRenderDimensions;
|
||||
|
||||
private _onCanvasResize = new EventEmitter2<{ width: number, height: number }>();
|
||||
public get onCanvasResize(): IEvent<{ width: number, height: number }> { return this._onCanvasResize.event; }
|
||||
private _onRender = new EventEmitter2<{ start: number, end: number }>();
|
||||
public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; }
|
||||
|
||||
constructor(private _terminal: ITerminal, theme: ITheme) {
|
||||
super();
|
||||
const allowTransparency = this._terminal.options.allowTransparency;
|
||||
@@ -62,7 +68,7 @@ export class Renderer extends EventEmitter implements IRenderer {
|
||||
this._updateDimensions();
|
||||
this.onOptionsChanged();
|
||||
|
||||
this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this));
|
||||
this._renderDebouncer = new RenderDebouncer(this._renderRows.bind(this));
|
||||
this._screenDprMonitor = new ScreenDprMonitor();
|
||||
this._screenDprMonitor.setListener(() => this.onWindowResize(window.devicePixelRatio));
|
||||
this.register(this._screenDprMonitor);
|
||||
@@ -134,7 +140,7 @@ export class Renderer extends EventEmitter implements IRenderer {
|
||||
this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth}px`;
|
||||
this._terminal.screenElement.style.height = `${this.dimensions.canvasHeight}px`;
|
||||
|
||||
this.emit('resize', {
|
||||
this._onCanvasResize.fire({
|
||||
width: this.dimensions.canvasWidth,
|
||||
height: this.dimensions.canvasHeight
|
||||
});
|
||||
@@ -188,7 +194,7 @@ export class Renderer extends EventEmitter implements IRenderer {
|
||||
this._needsFullRefresh = true;
|
||||
return;
|
||||
}
|
||||
this._renderDebouncer.refresh(start, end);
|
||||
this._renderDebouncer.refresh(start, end, this._terminal.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +203,7 @@ export class Renderer extends EventEmitter implements IRenderer {
|
||||
*/
|
||||
private _renderRows(start: number, end: number): void {
|
||||
this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end));
|
||||
this._terminal.emit('refresh', { start, end });
|
||||
this._onRender.fire({ start, end });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CharData, ITerminal, ICellData } from '../Types';
|
||||
import { GridCache } from './GridCache';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
import { CellData, AttributeData, Content } from '../BufferLine';
|
||||
import { JoinedCellData } from './CharacterJoinerRegistry';
|
||||
|
||||
/**
|
||||
* This CharData looks like a null character, which will forc a clear and render
|
||||
@@ -89,15 +90,12 @@ export class TextRenderLayer extends BaseRenderLayer {
|
||||
|
||||
// We already know the exact start and end column of the joined range,
|
||||
// so we get the string and width representing it directly
|
||||
cell = CellData.fromCharData([
|
||||
0,
|
||||
|
||||
cell = new JoinedCellData(
|
||||
this._workCell,
|
||||
line.translateToString(true, range[0], range[1]),
|
||||
range[1] - range[0],
|
||||
0xFFFFFF
|
||||
]);
|
||||
// hacky: patch attrs
|
||||
cell.fg = this._workCell.fg;
|
||||
cell.bg = this._workCell.bg;
|
||||
range[1] - range[0]
|
||||
);
|
||||
|
||||
// Skip over the cells occupied by this range in the loop
|
||||
lastCharX = range[1] - 1;
|
||||
@@ -217,10 +215,18 @@ export class TextRenderLayer extends BaseRenderLayer {
|
||||
} else {
|
||||
this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css;
|
||||
}
|
||||
} else if (cell.isFgRGB()) {
|
||||
this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
|
||||
} else if (cell.isFgPalette()) {
|
||||
this._ctx.fillStyle = this._colors.ansi[cell.getFgColor()].css;
|
||||
} else {
|
||||
if (cell.isFgDefault()) {
|
||||
this._ctx.fillStyle = this._colors.foreground.css;
|
||||
} else if (cell.isFgRGB()) {
|
||||
this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
|
||||
} else {
|
||||
let fg = cell.getFgColor();
|
||||
if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) {
|
||||
fg += 8;
|
||||
}
|
||||
this._ctx.fillStyle = this._colors.ansi[fg].css;
|
||||
}
|
||||
}
|
||||
|
||||
this.fillBottomLineAtCells(x, y, cell.getWidth());
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { ITerminal, CharacterJoinerHandler } from '../Types';
|
||||
import { IEventEmitter, ITheme, IDisposable } from 'xterm';
|
||||
import { ITheme, IDisposable } from 'xterm';
|
||||
import { IEvent } from '../common/EventEmitter2';
|
||||
|
||||
/**
|
||||
* Flags used to render terminal text properly.
|
||||
@@ -23,10 +24,13 @@ export const enum FLAGS {
|
||||
* Note that IRenderer implementations should emit the refresh event after
|
||||
* rendering rows to the screen.
|
||||
*/
|
||||
export interface IRenderer extends IEventEmitter, IDisposable {
|
||||
export interface IRenderer extends IDisposable {
|
||||
dimensions: IRenderDimensions;
|
||||
colorManager: IColorManager;
|
||||
|
||||
onCanvasResize: IEvent<{ width: number, height: number }>;
|
||||
onRender: IEvent<{ start: number, end: number }>;
|
||||
|
||||
dispose(): void;
|
||||
setTheme(theme: ITheme): IColorSet;
|
||||
onWindowResize(devicePixelRatio: number): void;
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*/
|
||||
|
||||
import { IRenderer, IRenderDimensions, IColorSet } from '../Types';
|
||||
import { ILinkHoverEvent, ITerminal, CharacterJoinerHandler, LinkHoverEventTypes } from '../../Types';
|
||||
import { ILinkifierEvent, ITerminal, CharacterJoinerHandler } from '../../Types';
|
||||
import { ITheme } from 'xterm';
|
||||
import { EventEmitter } from '../../common/EventEmitter';
|
||||
import { ColorManager } from '../ColorManager';
|
||||
import { RenderDebouncer } from '../../ui/RenderDebouncer';
|
||||
import { BOLD_CLASS, ITALIC_CLASS, CURSOR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_BLINK_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DomRendererRowFactory } from './DomRendererRowFactory';
|
||||
import { INVERTED_DEFAULT_COLOR } from '../atlas/Types';
|
||||
import { EventEmitter2, IEvent } from '../../common/EventEmitter2';
|
||||
import { Disposable } from '../../common/Lifecycle';
|
||||
|
||||
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
|
||||
const ROW_CONTAINER_CLASS = 'xterm-rows';
|
||||
@@ -29,7 +30,7 @@ let nextTerminalId = 1;
|
||||
* particularly fast or feature complete, more just stable and usable for when
|
||||
* canvas is not an option.
|
||||
*/
|
||||
export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
export class DomRenderer extends Disposable implements IRenderer {
|
||||
private _renderDebouncer: RenderDebouncer;
|
||||
private _rowFactory: DomRendererRowFactory;
|
||||
private _terminalClass: number = nextTerminalId++;
|
||||
@@ -43,6 +44,11 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
public dimensions: IRenderDimensions;
|
||||
public colorManager: ColorManager;
|
||||
|
||||
private _onCanvasResize = new EventEmitter2<{ width: number, height: number }>();
|
||||
public get onCanvasResize(): IEvent<{ width: number, height: number }> { return this._onCanvasResize.event; }
|
||||
private _onRender = new EventEmitter2<{ start: number, end: number }>();
|
||||
public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; }
|
||||
|
||||
constructor(private _terminal: ITerminal, theme: ITheme | undefined) {
|
||||
super();
|
||||
const allowTransparency = this._terminal.options.allowTransparency;
|
||||
@@ -74,15 +80,15 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
};
|
||||
this._updateDimensions();
|
||||
|
||||
this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this));
|
||||
this._renderDebouncer = new RenderDebouncer(this._renderRows.bind(this));
|
||||
this._rowFactory = new DomRendererRowFactory(_terminal.options, document);
|
||||
|
||||
this._terminal.element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._terminal.screenElement.appendChild(this._rowContainer);
|
||||
this._terminal.screenElement.appendChild(this._selectionContainer);
|
||||
|
||||
this._terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: ILinkHoverEvent) => this._onLinkHover(e));
|
||||
this._terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: ILinkHoverEvent) => this._onLinkLeave(e));
|
||||
this._terminal.linkifier.onLinkHover(e => this._onLinkHover(e));
|
||||
this._terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e));
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
@@ -168,9 +174,9 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
// Blink animation
|
||||
styles +=
|
||||
`@keyframes blink {` +
|
||||
` 0 % { opacity: 1.0; }` +
|
||||
` 0% { opacity: 1.0; }` +
|
||||
` 50% { opacity: 0.0; }` +
|
||||
` 100 % { opacity: 1.0; }` +
|
||||
` 100% { opacity: 1.0; }` +
|
||||
`}`;
|
||||
// Cursor
|
||||
styles +=
|
||||
@@ -238,6 +244,10 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
public onResize(cols: number, rows: number): void {
|
||||
this._refreshRowElements(cols, rows);
|
||||
this._updateDimensions();
|
||||
this._onCanvasResize.fire({
|
||||
width: this.dimensions.canvasWidth,
|
||||
height: this.dimensions.canvasHeight
|
||||
});
|
||||
}
|
||||
|
||||
public onCharSizeChanged(): void {
|
||||
@@ -330,7 +340,7 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
}
|
||||
|
||||
public refreshRows(start: number, end: number): void {
|
||||
this._renderDebouncer.refresh(start, end);
|
||||
this._renderDebouncer.refresh(start, end, this._terminal.rows);
|
||||
}
|
||||
|
||||
private _renderRows(start: number, end: number): void {
|
||||
@@ -350,7 +360,7 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.actualCellWidth, terminal.cols));
|
||||
}
|
||||
|
||||
this._terminal.emit('refresh', {start, end});
|
||||
this._onRender.fire({ start, end });
|
||||
}
|
||||
|
||||
private get _terminalSelector(): string {
|
||||
@@ -360,11 +370,11 @@ export class DomRenderer extends EventEmitter implements IRenderer {
|
||||
public registerCharacterJoiner(handler: CharacterJoinerHandler): number { return -1; }
|
||||
public deregisterCharacterJoiner(joinerId: number): boolean { return false; }
|
||||
|
||||
private _onLinkHover(e: ILinkHoverEvent): void {
|
||||
private _onLinkHover(e: ILinkifierEvent): void {
|
||||
this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, true);
|
||||
}
|
||||
|
||||
private _onLinkLeave(e: ILinkHoverEvent): void {
|
||||
private _onLinkLeave(e: ILinkifierEvent): void {
|
||||
this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,16 @@ describe('DomRendererRowFactory', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should add class for underline', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
|
||||
lineData.setCell(0, cell);
|
||||
const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20);
|
||||
assert.equal(getFragmentHtml(fragment),
|
||||
'<span class="xterm-underline">a</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add classes for 256 foreground colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg |= Attributes.CM_P256;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CellData, AttributeData } from '../../BufferLine';
|
||||
export const BOLD_CLASS = 'xterm-bold';
|
||||
export const DIM_CLASS = 'xterm-dim';
|
||||
export const ITALIC_CLASS = 'xterm-italic';
|
||||
export const UNDERLINE_CLASS = 'xterm-underline';
|
||||
export const CURSOR_CLASS = 'xterm-cursor';
|
||||
export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink';
|
||||
export const CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block';
|
||||
@@ -88,6 +89,10 @@ export class DomRendererRowFactory {
|
||||
charElement.classList.add(DIM_CLASS);
|
||||
}
|
||||
|
||||
if (this._workCell.isUnderline()) {
|
||||
charElement.classList.add(UNDERLINE_CLASS);
|
||||
}
|
||||
|
||||
charElement.textContent = this._workCell.getChars() || WHITESPACE_CELL_CHAR;
|
||||
|
||||
const swapColor = this._workCell.isInverse();
|
||||
|
||||
Reference in New Issue
Block a user