/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
import { type Color, type CellStyle, IsHTMLColor, IsThemeColor, ThemeColorIndex, type ThemeColor, type FontSize } from './style';
import { ColorFunctions } from './color';
// import * as LCHColorFunctions from './color2';
import { DOMContext } from './dom-utilities';
import { Measurement } from 'treb-utils';
import { font_stack_names, type FontStack, GenerateFontStack } from './font-stack';
/*
* so this is a little strange. we use CSS to populate a theme object,
* then we create HTML nodes and assign styles to them from the theme
* object. we should cut out the middleman and use CSS to style nodes.
*
* how did we get here? because we paint, we originally used a theme object.
* it made sense to put non-painted theme info in there so it was all in one
* place.
*
* then subsequently we figured out we could use css, apply it, and read out
* values to populate the theme. so the theme object became internal-only,
* but still held all the HTML theme data.
*
* I guess it didn't occur to me at the time that you could use CSS for
* everything except painting, and then pull out only those values you
* actually need for painting.
*
* It has since occurred to me, and this should be a focus of future
* development (thinking v9). not sure what the overall benefits will be,
* but it should reduce complexity at least a little.
*
* As part of that I want to generally switch to percentage font sizes for
* spreadsheet cells.
*/
/**
* composite styling for tables.
*
* @privateRemarks
*
* we used to have a "footer", now removed. use borders on rows.
*/
export interface TableTheme {
/** the first row in a table, showing column titles. */
header?: CellStyle;
/**
* odd rows in the table. we count the title row as zero, so
* the first row in the table containing data is 1, hence odd.
*/
odd?: CellStyle;
/**
* even rows in the table.
*/
even?: CellStyle;
/**
* styling for the totals row, if included. this will be the last
* row in the table.
*/
total?: CellStyle;
}
/** theme options - colors and fonts */
export interface Theme {
/** grid headers (composite) */
headers?: CellStyle;
/** grid cell defaults (composite: size, font face, color, background) */
grid_cell?: CellStyle;
/**
* base font size for grid cell. we try to specify things in ems, but
* we do need to know this in order to measure
*/
grid_cell_font_size: FontSize;
/** gridlines color */
grid_color: string;
/**
* new: gridlines color for headers. should default to the regular grid
* color unless it's explicitly set.
*/
headers_grid_color?: string;
/** color of grid lines */
// grid?: Style.Properties;
/** color of in-cell note marker */
note_marker_color: string;
/** theme colors */
theme_colors?: string[];
/** as RGB, so we can adjust them */
theme_colors_rgb?: [number, number, number][];
/**
* cache tinted colors. the way this works is we index by the
* theme color first, then by the tint value.
*
* TODO: we could reduce the tint space... the values look like
* they are fairly regular (todo)
*
* what we are doing now is rounding to 2 decimal places on import, that
* cleans up the super precise values we get from excel to more reasonable
* values for keys, and I don't think most people can tell the difference
* between tinting 25% vs 24.99999872%
*/
tint_cache?: Record[];
/**
* cache for offset colors
*/
offset_cache?: Record;
/**
* this is now default, but you can set explicitly per-table
*/
table?: TableTheme;
/**
* this is for tinting. we're experimenting with tinting towards black
* or white, as opposed to lightening/darkening colors. this should improve
* swapping themed colors.
*
* how to derive this value? @see DeriveColorScheme
*/
mode: 'light'|'dark';
/** light color for offset (against dark background) */
offset_light: string;
/** dark color for offset (against light background) */
offset_dark: string;
/** precalculated font stacks */
font_stacks: Record;
}
/**
* @internal
*/
export const DefaultTheme: Theme = {
grid_color: '#ccc',
note_marker_color: '#d2c500',
mode: 'light',
offset_cache: {},
offset_light: '#fff',
offset_dark: '#000',
font_stacks: {},
grid_cell_font_size: { value: 10, unit: 'pt' },
};
/* *
* now just a wrapper, we should remove
*
* the only difference between this and the other function (ThemeColor2)
* is that this has a default for "defaultindex" => 0; calls can just
* call the second method with the extra argument.
*
* @deprecated
* @internal
* /
export const ThemeColor = (theme: Theme, color?: Color): string => {
return ThemeColor2(theme, color, 0);
};
*/
/**
* we cache values in the theme object so that we can dump it when we
* reload or update the theme.
*
* we're now inverting tint for dark themes. the idea is that if you
* are using a dark theme, it's more natural to go in that direction, and
* you can use the same foreground color.
*
* because this is ephemeral it won't impact export.
*/
const TintedColor = (theme: Theme, source: ThemeColor) => {
const index = ThemeColorIndex(source);
let tint = (source.tint || 0);
if (theme.mode === 'dark') {
tint = -tint; // invert;
}
if (!theme.tint_cache) {
theme.tint_cache = [];
}
if (!theme.tint_cache[index]) {
theme.tint_cache[index] = {};
}
let color = theme.tint_cache[index][tint];
if (!color) {
const rgb: [number, number, number] = (theme.theme_colors_rgb ? theme.theme_colors_rgb[index] : [0, 0, 0]) || [0, 0, 0];
let tinted: {r: number, g: number, b: number};
if (tint > 0) {
tinted = ColorFunctions.Lighten(rgb[0], rgb[1], rgb[2], tint * 100, true);
}
else {
tinted = ColorFunctions.Darken(rgb[0], rgb[1], rgb[2], -tint * 100, true);
}
color = `rgb(${tinted.r},${tinted.g},${tinted.b})`;
/*
// L is in [0, 100] and the passed tint value is between (-1 and 1). but
// is the tint value the desired value, or the adjustment? (...)
// looks like it's the adjustment. makes sense I guess if you pick the
// original colors.
// if the tint value was not originally in perceptual space we might need
// to curve it a bit? (...)
const lch = LCHColorFunctions.RGBToLCH(...rgb);
const tinted = LCHColorFunctions.LCHToRGB(lch.l + 50 * tint, lch.c, lch.h);
color = `rgb(${tinted.r},${tinted.g},${tinted.b})`;
*/
theme.tint_cache[index][tint] = color;
}
return color;
};
/**
* this includes an implicit check for valid color, if a color
* can't be resolved it returns ''. now supports offset colors.
* offset returns a light color against a dark background, and
* vice versa. what constitutes a dark background is not entirely
* clear; atm using lightness = .65.
*
* @internal
*/
export const ResolveThemeColor = (theme: Theme, color?: Color, default_index?: number): string => {
if (color && color.offset) {
// don't do this
if (color.offset.offset) {
console.warn('invalid offset color');
return '';
}
let resolved = '';
if (IsHTMLColor(color.offset)) {
const clamped = Measurement.MeasureColor(color.offset.text);
resolved = `rgb(${clamped[0]}, ${clamped[1]}, ${clamped[2]})`;
}
else {
resolved = ResolveThemeColor(theme, color.offset, undefined);
}
// check cache
if (theme.offset_cache && theme.offset_cache[resolved]) {
return theme.offset_cache[resolved];
}
let offset = theme.offset_light;
if (resolved) {
// ok figure it out?
const match = resolved.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
type ColorTuple = [number, number, number];
const background: ColorTuple = [Number(match[1]), Number(match[2]), Number(match[3])];
// const hsl = ColorFunctions.RGBToHSL(r, g, b);
// const check = ColorFunctions.GetLuminance(r, g, b);
const a = Array.from(Measurement.MeasureColor(theme.offset_dark)) as ColorTuple;
const b = Array.from(Measurement.MeasureColor(theme.offset_light)) as ColorTuple;
const tc = ColorFunctions.GetTextColor(background, a, b);
offset = `rgb(${tc[0]}, ${tc[1]}, ${tc[2]})`;
/*
if (hsl.l >.65)) {
offset = theme.offset_dark;
}
*/
}
else {
// ...
console.warn(`can't offset against color`, resolved, '(1)');
}
if (!theme.offset_cache) {
theme.offset_cache = {};
}
theme.offset_cache[resolved] = offset;
}
else {
console.warn(`can't resolve offset color`, color.offset, '(2)');
}
return offset;
}
// explicit color, or none
if (IsHTMLColor(color)) {
return color.text === 'none' ? '' : color.text;
}
// theme color. we need a way to cache these lookups, especially for tinting
if (IsThemeColor(color)) {
if (color.tint) {
return TintedColor(theme, color);
}
return theme.theme_colors ? theme.theme_colors[ThemeColorIndex(color)] : '';
}
// default from argument
if (default_index || default_index === 0) {
return theme.theme_colors ? theme.theme_colors[default_index] : '';
}
// actual default, which is nothing
return '';
};
const ParseFontSize = (size: string): { value: number, unit: 'pt'|'px'|'em'|'%' } => {
let value = 10;
let unit:'pt'|'px'|'em'|'%' = 'pt';
const match = size.match(/^([\d.]+)(\D.*)$/); // pt, px, em, rem, %
if (match) {
value = Number(match[1]);
unit = match[2] as 'pt'|'px'|'em'|'%';
}
return { value, unit };
};
/* *
* pull out styles we apply to tables, if they differ from the base.
* setting "initial" or "inherit" doesn't work to clear them (at least atm);
* use "transparent" to unset.
* /
const TableStyleFromCSS = (base: CSSStyleDeclaration, style: CSSStyleDeclaration): Style.Properties => {
const props: Style.Properties = {};
if (style.borderTopColor !== base.borderTopColor) {
props.border_top = 1;
props.border_top_fill = { text: style.borderTopColor };
if (style.borderTopStyle === 'double') {
props.border_top = 2;
}
}
if (style.borderBottomColor !== base.borderBottomColor) {
props.border_bottom = 1;
props.border_bottom_fill = { text: style.borderBottomColor };
if (style.borderBottomStyle === 'double') {
props.border_bottom = 2;
}
}
if (style.backgroundColor !== base.backgroundColor) {
props.fill = { text: style.backgroundColor };
}
if (style.color !== base.color) {
props.text = { text: style.color };
}
if (style.fontWeight !== base.fontWeight) {
props.bold = /(?:700|bold)/.test(style.fontWeight);
}
if (style.fontStyle !== base.fontStyle) {
props.italic = /italic/.test(style.fontStyle);
}
if (style.textDecoration !== base.textDecoration) {
const style_underline = /underline/.test(style.textDecoration);
const base_underline = /underline/.test(base.textDecoration);
if (base_underline !== style_underline) {
props.underline = style_underline;
}
const style_strike = /line-through/.test(style.textDecoration);
const base_strike = /line-through/.test(base.textDecoration);
if (base_strike !== style_strike) {
props.strike = style_strike;
}
}
return props;
};
*/
// testing
const StyleFromCSS = (css: CSSStyleDeclaration, include_font_face = false): CellStyle => {
// const { value, unit } = ParseFontSize(css.fontSize||'');
const style: CellStyle = {
fill: { text: css.backgroundColor }, // || 'none',
text: { text: css.color },
/*
font_size: {
unit, value,
},
*/
// use container size unless we scale. the reason we do this is
// because if we set scale, we always wind up with em units.
font_size: { unit: 'em', value: 1 },
// font_face: css.fontFamily,
};
if (include_font_face) {
style.font_face = css.fontFamily;
}
// the default border comes from the "theme colors", not from
// the CSS property (it used to come from the CSS property, which
// is why we have the CSS property set).
//
// default border is theme color 1.
//
if (/italic/i.test(css.font)) {
style.italic = true;
}
// for painting we only support regular (400) and bold (700), those
// are the only values allowed by canvas (I think)
if (css.fontWeight === '700') {
style.bold = true;
}
return style;
}
/**
* how to derive the light/dark theme? it's complicated, as it turns out.
* there's almost nothing we can do to reliably determine what theme
* is set. the best thing would be color-scheme, which affects (among other
* things) scrollbars, but that might be set to something like 'light dark'
* which is indeterminate.
*
* so what we are going to do is check the grid foreground and background;
* if the foreground is lighter than the background, we're in dark mode.
* and vice-versa.
*
*/
const DeriveColorScheme = (theme: Theme, context: CanvasRenderingContext2D): 'light' | 'dark' => {
const foreground_color = theme.grid_cell?.text;
const background_color = theme.grid_cell?.fill;
// because these are rendered to a canvas, we know that A is 255
context.fillStyle = IsHTMLColor(foreground_color) ? foreground_color.text : '';
context.fillRect(0, 0, 3, 3);
const fg = ColorFunctions.RGBToHSL(...(Array.from(context.getImageData(1, 1, 1, 1).data) as [number, number, number]));
context.fillStyle = IsHTMLColor(background_color) ? background_color.text : '';
context.fillRect(0, 0, 3, 3);
const bg = ColorFunctions.RGBToHSL(...(Array.from(context.getImageData(1, 1, 1, 1).data) as [number, number, number]));
// console.info({fg, bg});
return fg.l > bg.l ? 'dark' : 'light';
}
/**
* this is a shortcut for creating table formats based on theme colors.
* TODO: we might want to swap styles based on light/dark mode?
*
* @internal
*/
export const ThemeColorTable = (theme_color: number, tint = .7): TableTheme => {
const borders: CellStyle = {
border_top: 1,
border_top_fill: { theme: theme_color },
border_bottom: 1,
border_bottom_fill: { theme: theme_color },
};
return {
header: {
// text: { theme: theme.mode === 'dark' ? 1 : 0, },
// text: { text: '#fff' },
text: { offset: {theme: theme_color} },
fill: {theme: theme_color},
bold: true,
...borders,
},
odd: {
fill: { theme: theme_color, tint },
...borders,
},
even: {
...borders,
},
total: {
...borders,
border_top: 2,
},
}
}
/**
* for stuff that's painted, we wamt to get the corresponding CSS value.
* we now set everything via CSS variables, but using the node structure
* allows us to read calculated values, especially when there are cascades.
*
* I keep trying to change this to just read CSS variables, but that does
* not do the same thing.
*
* @internal
*/
export const LoadThemeProperties = (container: HTMLElement, use_font_stacks = false): Theme => {
const theme: Theme = JSON.parse(JSON.stringify(DefaultTheme));
const DOM = DOMContext.GetInstance(container.ownerDocument);
const Append = (parent: HTMLElement, classes: string): HTMLDivElement => {
return DOM.Div(classes, parent);
}
const ElementCSS = (parent: HTMLElement, classes: string): CSSStyleDeclaration => {
return DOM.view?.getComputedStyle(Append(parent, classes)) as CSSStyleDeclaration;
}
const node = Append(container, '');
const CSS: (classes: string) => CSSStyleDeclaration = ElementCSS.bind(0, node);
let css = CSS('grid-cells');
theme.grid_cell = StyleFromCSS(css, false);
theme.grid_color = css.stroke || '';
theme.grid_cell_font_size = ParseFontSize(css.fontSize||'');
// console.info({theme});
if (use_font_stacks) {
for (const key of font_stack_names) {
css = CSS(`treb-font-stack-${key}`);
theme.font_stacks[key] = GenerateFontStack(key, css);
}
}
else {
// default only
css = CSS(`treb-font-stack-default`);
theme.font_stacks.default = GenerateFontStack('default', css);
}
// console.info(theme.font_stacks);
css = CSS('grid-headers');
theme.headers = StyleFromCSS(css, true);
theme.headers_grid_color = css.stroke;
if (!theme.headers_grid_color || theme.headers_grid_color === 'none') {
theme.headers_grid_color = theme.grid_color;
}
css = CSS('treb-offset-dark');
if (css.color) {
theme.offset_dark = css.color;
}
css = CSS('treb-offset-light');
if (css.color) {
theme.offset_light = css.color;
}
// this _is_ painted, but it doesn't necessarily need to be -- we
// could use a node. that would require moving it around, though.
// let's leave it for now.
css = CSS('note-marker');
theme.note_marker_color = css.backgroundColor;
// updating tables. we're now defining tables as Style.Properties
// directly. the aim is to use theme colors so we can have multiple
// table styles without too much fuss.
/*
const root_css = CSS('');
theme.table = {
header: TableStyleFromCSS(root_css, CSS('treb-table header')),
odd: TableStyleFromCSS(root_css, CSS('treb-table row-odd')),
even: TableStyleFromCSS(root_css, CSS('treb-table row-even')),
// footer: TableStyleFromCSS(root_css, CSS('treb-table footer')),
total: TableStyleFromCSS(root_css, CSS('treb-table total')),
}
*/
// console.info(theme.table);
// theme colors
node.style.color='rgba(1,2,3,.4)'; // this is an attempt at a unique identifier
css = CSS('');
const compare = css.color;
theme.theme_colors = [
IsHTMLColor(theme.grid_cell.fill) ? theme.grid_cell.fill.text : 'rgb(255, 255, 255)',
IsHTMLColor(theme.grid_cell.text) ? theme.grid_cell.text.text : 'rgb(51, 51, 51)',
];
for (let i = 1; i < 32; i++) {
css = CSS(`theme-color-${i}`);
if (!css.color || css.color === compare) {
break;
}
theme.theme_colors.push(css.color);
}
// we could just parse, we know the returned css format is going
// to be an rgb triple (I think?)
const canvas = DOM.Create('canvas');
canvas.width = 3;
canvas.height = 3;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (context) {
theme.mode = DeriveColorScheme(theme, context);
}
if (context) {
theme.theme_colors_rgb = theme.theme_colors.map((color) => {
context.fillStyle = color;
context.fillRect(0, 0, 3, 3);
const imagedata = context.getImageData(1, 1, 1, 1);
return Array.from(imagedata.data) as [number, number, number];
});
}
theme.table = ThemeColorTable(4);
// this is a little odd, since we have the check above for "existing element";
// should we switch on that? or is that never used, and we can drop it? (...)
// console.info(node);
// console.info(theme);
(node.parentElement as Element)?.removeChild(node);
return theme;
};