diff --git a/candle_countdown.go b/candle_countdown.go new file mode 100644 index 0000000..7f5f566 --- /dev/null +++ b/candle_countdown.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CandleCountdownOptions configure a CandleCountdown. +type CandleCountdownOptions struct { + // Location aligns days and weeks; nil is UTC, as exchanges count. + Location *time.Location +} + +// CandleCountdown shows the time left until the current candle of interval closes; it wakes once a second only while shown. +func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element { + loc := opts.Location + if loc == nil { + loc = time.UTC + } + now := c.Now() + left := interval.Close(now, loc).Sub(now) + k := Tokens(c) + text := candleCountdownText(left) + e := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleText).Label(fmt.Sprintf(msg(c, "candle_countdown.label"), string(interval), text)) + e.Children(func() { + ui.Text(c, string(interval)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Text(c, text).FontFeatures("tnum").FontWeight(500).TextColor(k.Text).SingleLine() + }) + // The next whole second, so the text never skips one. + c.After(left - left.Truncate(time.Second) + time.Millisecond) + return e +} + +// candleCountdownText writes d as 59:58, 3:59:58 or 2d 03:59:58. +func candleCountdownText(d time.Duration) string { + s := int(d.Seconds()) + days, h, m, sec := s/86400, s/3600%24, s/60%60, s%60 + switch { + case days > 0: + return fmt.Sprintf("%dd %02d:%02d:%02d", days, h, m, sec) + case h > 0: + return fmt.Sprintf("%d:%02d:%02d", h, m, sec) + } + return fmt.Sprintf("%02d:%02d", m, sec) +} diff --git a/chart_type_switcher.go b/chart_type_switcher.go new file mode 100644 index 0000000..7102cf4 --- /dev/null +++ b/chart_type_switcher.go @@ -0,0 +1,35 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" +) + +// ChartTypeSwitcherOptions configure a ChartTypeSwitcher. +type ChartTypeSwitcherOptions struct { + Disabled bool +} + +// ChartTypeSwitcher chooses how CandlestickChart draws: candles, OHLC bars, line, area or Heikin-Ashi. +func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOptions) ChoiceResult { + if value == nil { + panic("mujica: ChartTypeSwitcher needs a value") + } + if *value < ChartCandles || *value > ChartHeikinAshi { + panic(fmt.Sprintf("mujica: unknown ChartType %d", *value)) + } + var items []ToggleItem[ChartType] + for t := ChartCandles; t <= ChartHeikinAshi; t++ { + items = append(items, ToggleItem[ChartType]{Value: t, Label: msg(c, fmt.Sprintf("chart_type_switcher.type%d", t))}) + } + sel := []ChartType{*value} + var r ChoiceResult + ui.Row(c).Disabled(opts.Disabled).Children(func() { + r = ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "chart_type_switcher.label")}) + }) + if r.Changed() { + *value = sel[0] + } + return r +} diff --git a/drawing_toolbar.go b/drawing_toolbar.go new file mode 100644 index 0000000..9341640 --- /dev/null +++ b/drawing_toolbar.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// DrawingToolbarOptions configure a DrawingToolbar. +type DrawingToolbarOptions struct { + // Horizontal lays the tools in a row; the default is a column beside the chart. + Horizontal bool +} + +// DrawingToolbar picks the drawing tool, locks, clears and undoes; Alt+1 to Alt+6 pick tools anywhere in the window. +func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) ChoiceResult { + if d == nil { + panic("mujica: DrawingToolbar needs drawings") + } + if d.Tool < ToolSelect || d.Tool > ToolText { + panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool)) + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + bar := ui.Column(c).Gap(u).Padding(u).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Role(ui.RoleToolbar).Label(msg(c, "drawing_toolbar.label")) + if opts.Horizontal { + bar.Row().FocusGroup(ui.Horizontal) + } else { + bar.FocusGroup(ui.Vertical) + } + pick := func(t DrawingTool) { + if !d.Locked && d.Tool != t && !bar.IsDisabled() { + d.Tool, changed = t, true + slog.Debug("mujica: drawing_toolbar tool", "tool", t) + } + } + digits := []ui.Key{ui.Key1, ui.Key2, ui.Key3, ui.Key4, ui.Key5, ui.Key6} + for t := ToolSelect; t <= ToolText; t++ { + if c.Shortcut(ui.Alt, digits[t]) { + pick(t) + } + } + bar.Children(func() { + for t := ToolSelect; t <= ToolText; t++ { + name := fmt.Sprintf("%s (Alt+%d)", msg(c, fmt.Sprintf("drawing_toolbar.tool%d", t)), t+1) + on := d.Tool == t + b := ui.ButtonBase(c).Key(t).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked) + internal.Ring(b, k.Focus, theme.ControlRadius) + fg := k.Text + switch { + case on: + b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText) + fg = k.AccentText + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Draw(func(p *ui.Painter, r ui.Rect) { drawingToolGlyph(p, r, t, fg) }) + if b.Clicked() { + pick(t) + } + } + div := ui.Box(c).Background(k.Border).Role(ui.RoleNone) + if opts.Horizontal { + div.Size(theme.BorderWidth, u*6) + } else { + div.Size(u*6, theme.BorderWidth) + } + lockName := msg(c, "drawing_toolbar.lock") + if d.Locked { + lockName = msg(c, "drawing_toolbar.unlock") + } + lock := iconAction(c, icons.Must("lock"), lockName).Tooltip(lockName) + if d.Locked { + lock.Background(k.Selection).TextColor(k.AccentText) + } + if lock.Clicked() && !bar.IsDisabled() { + d.Locked, changed = !d.Locked, true + d.Tool = ToolSelect + slog.Debug("mujica: drawing_toolbar lock", "locked", d.Locked) + } + undo := iconAction(c, icons.Must("rotate-ccw"), msg(c, "drawing_toolbar.undo")).Tooltip(msg(c, "drawing_toolbar.undo")).Disabled(!d.CanUndo()) + if undo.Clicked() && d.Undo() { + changed = true + } + clear := iconAction(c, icons.Must("trash-2"), msg(c, "drawing_toolbar.clear")).Tooltip(msg(c, "drawing_toolbar.clear")).Disabled(d.Locked || len(d.Items) == 0) + if clear.Clicked() && d.Clear() { + changed = true + } + }) + return ChoiceResult{bar, changed} +} + +// drawingToolGlyph draws a small picture of tool t in r. +func drawingToolGlyph(p *ui.Painter, r ui.Rect, t DrawingTool, col ui.Color) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + s := float32(8) + switch t { + case ToolSelect: + var path ui.Path + path.MoveTo(cx-5, cy-7).LineTo(cx+5, cy+1).LineTo(cx, cy+1).LineTo(cx+3, cy+7).LineTo(cx+1, cy+8).LineTo(cx-2, cy+2).LineTo(cx-5, cy+5).Close() + p.FillPath(&path, col) + case ToolTrendLine: + p.Line(cx-s, cy+s, cx+s, cy-s, 1.5, col) + p.FillPath(internal.Diamond(cx-s, cy+s, 5), col) + p.FillPath(internal.Diamond(cx+s, cy-s, 5), col) + case ToolHorizontal: + p.Line(cx-s, cy, cx+s, cy, 1.5, col) + p.FillPath(internal.Diamond(cx, cy, 5), col) + case ToolFibonacci: + for i, f := range []float32{-1, -0.4, 0.2, 1} { + w := []float32{1.5, 1, 1, 1.5}[i] + p.Line(cx-s, cy+f*s, cx+s, cy+f*s, w, col) + } + case ToolRectangle: + p.Stroke(ui.Rect{X: cx - s, Y: cy - s*0.7, W: 2 * s, H: 1.4 * s}, col, 1, 1.5) + case ToolText: + p.Line(cx-s*0.8, cy-s, cx+s*0.8, cy-s, 2, col) + p.Line(cx, cy-s, cx, cy+s, 2, col) + } +} diff --git a/indicator_selector.go b/indicator_selector.go new file mode 100644 index 0000000..7e82067 --- /dev/null +++ b/indicator_selector.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// IndicatorSelectorOptions configure an IndicatorSelector. +type IndicatorSelectorOptions struct { + Disabled bool +} + +// IndicatorSelector opens a panel to search, add, remove and tune indicators: overlays go on the price chart, the rest into panes. +func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *IndicatorPanes, opts IndicatorSelectorOptions) ChoiceResult { + if overlays == nil || panes == nil { + panic("mujica: IndicatorSelector needs overlays and panes") + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + box := ui.Row(c).Disabled(opts.Disabled) + box.Children(func() { + open := ui.Local(box, "indicator_selector.open", func() bool { return false }) + query := ui.Local(box, "indicator_selector.query", func() string { return "" }) + if box.IsDisabled() { + *open = false + } + label := fmt.Sprintf(msg(c, "indicator_selector.button"), len(*overlays)+len(panes.Panes)) + trigger := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}) + if trigger.Clicked() { + *open = !*open + } + Popover(c, trigger, open, PopoverOptions{Title: msg(c, "indicator_selector.title"), Width: 340}, func() { + TextInput(c, query, TextInputOptions{Placeholder: msg(c, "indicator_selector.search"), Label: msg(c, "indicator_selector.search"), Clearable: true}) + ui.Text(c, msg(c, "indicator_selector.available")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, 0) + found := 0 + for kind := IndicatorMA; kind <= IndicatorOBV; kind++ { + name := kind.String() + if q := strings.TrimSpace(*query); q != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(q)) { + continue + } + found++ + ui.Row(c).Key(name).AlignItems(ui.Center).Gap(u * 2).MinHeight(Density(c).ControlHeight()).Children(func() { + ui.Text(c, name).FontWeight(600).Width(56) + where := "indicator_selector.pane" + if indicatorInfo[kind].overlay { + where = "indicator_selector.overlay" + } + ui.Text(c, msg(c, where)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1) + if Button(c, msg(c, "indicator_selector.add"), ButtonOptions{Variant: Ghost, Label: fmt.Sprintf(msg(c, "indicator_selector.add_named"), name)}).Clicked() { + if indicatorInfo[kind].overlay { + *overlays = append(slices.Clone(*overlays), IndicatorOverlay{Kind: kind, Params: kind.DefaultParams()}) + } else { + panes.Panes = append(slices.Clone(panes.Panes), IndicatorPaneSpec{Kind: kind, Params: kind.DefaultParams()}) + } + changed = true + slog.Debug("mujica: indicator_selector add", "kind", name) + } + }) + } + if found == 0 { + ui.Text(c, msg(c, "indicator_selector.none")).TextColor(k.TextMuted) + } + if len(*overlays)+len(panes.Panes) == 0 { + return + } + ui.Text(c, msg(c, "indicator_selector.active")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0) + remove := -1 + for i := range *overlays { + if indicatorSelectorActive(c, u, fmt.Sprint("o", i), (*overlays)[i].Kind, &(*overlays)[i].Params, &changed) { + remove = i + } + } + if remove >= 0 { + *overlays = slices.Delete(slices.Clone(*overlays), remove, remove+1) + changed = true + } + remove = -1 + for i := range panes.Panes { + if indicatorSelectorActive(c, u, fmt.Sprint("p", i), panes.Panes[i].Kind, &panes.Panes[i].Params, &changed) { + remove = i + } + } + if remove >= 0 { + panes.Panes = slices.Delete(slices.Clone(panes.Panes), remove, remove+1) + changed = true + } + }) + }) + return ChoiceResult{box, changed} +} + +// indicatorSelectorActive is one added indicator with its parameters and a remove button; it reports removal. +func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool { + removed := false + ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() { + ui.Text(c, kind.String()).FontWeight(600).Width(56) + if len(kind.DefaultParams()) > 0 && IndicatorParamsField(c, kind, params).Changed() { + *changed = true + } + ui.Spacer(c) + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), indicatorName(kind, *params))).Clicked() { + removed = true + slog.Debug("mujica: indicator_selector remove", "kind", kind) + } + }) + return removed +} diff --git a/interval_selector.go b/interval_selector.go new file mode 100644 index 0000000..693ebcf --- /dev/null +++ b/interval_selector.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// intervalGroups sorts Intervals under their headings. +var intervalGroups = []struct { + key string + items []Interval +}{ + {"interval_selector.minutes", []Interval{"1m", "3m", "5m", "15m", "30m"}}, + {"interval_selector.hours", []Interval{"1h", "2h", "4h", "6h", "12h"}}, + {"interval_selector.days", []Interval{"1D", "3D", "1W", "1M"}}, +} + +// IntervalSelectorOptions configure an IntervalSelector. +type IntervalSelectorOptions struct { + Disabled bool +} + +// IntervalSelector picks a candle period: favorites show as quick buttons, and a grouped list holds all, each with a star. +func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opts IntervalSelectorOptions) ChoiceResult { + if value == nil || favorites == nil { + panic("mujica: IntervalSelector needs a value and favorites") + } + for _, iv := range append([]Interval{*value}, *favorites...) { + if !slices.Contains(Intervals, iv) { + panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv))) + } + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled) + disabled := row.IsDisabled() + row.Children(func() { + quick := slices.Clone(*favorites) + if !slices.Contains(quick, *value) { + quick = append(quick, *value) + } + slices.SortFunc(quick, func(a, b Interval) int { return slices.Index(Intervals, a) - slices.Index(Intervals, b) }) + items := make([]ToggleItem[Interval], len(quick)) + for i, iv := range quick { + items[i] = ToggleItem[Interval]{Value: iv, Label: string(iv)} + } + sel := []Interval{*value} + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "interval_selector.label")}).Changed() && !disabled { + *value, changed = sel[0], true + } + trigger := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("chevron-down"), Label: msg(c, "interval_selector.all")}) + open := ui.Local(row, "interval_selector.open", func() bool { return false }) + if disabled { + *open = false + } + if trigger.Clicked() && !disabled { + *open = !*open + } + Popover(c, trigger, open, PopoverOptions{Label: msg(c, "interval_selector.all"), Width: 220}, func() { + for _, g := range intervalGroups { + ui.Text(c, msg(c, g.key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, u) + for _, iv := range g.items { + if intervalSelectorRow(c, k, u, iv, iv == *value, slices.Contains(*favorites, iv), favorites) { + *value, changed, *open = iv, true, false + } + } + } + }) + }) + if changed { + slog.Debug("mujica: interval_selector changed", "interval", *value) + } + return ChoiceResult{row, changed} +} + +// intervalSelectorRow is one interval with its star; it reports choosing the interval. +func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool { + chosen := false + ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() { + b := ui.ButtonBase(c).Grow(1).MinHeight(Density(c).ControlHeight()).Padding(0, u*2).Justify(ui.Start).Radius(theme.ControlRadius).Gap(u * 2) + internal.Ring(b, k.Focus, theme.ControlRadius) + if on { + b.Background(k.Selection).TextColor(k.AccentText) + } else if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { + if on { + internal.Mark(p, r, "check", k.AccentText) + } + }) + ui.Text(c, string(iv)).SingleLine() + }) + chosen = b.Clicked() + name := fmt.Sprintf(msg(c, "interval_selector.favorite"), iv) + if fav { + name = fmt.Sprintf(msg(c, "interval_selector.unfavorite"), iv) + } + star := iconAction(c, icons.Must("star"), name) + if fav { + star.TextColor(k.Warning) + } + if star.Clicked() && !star.IsDisabled() { + if fav { + *favorites = slices.DeleteFunc(slices.Clone(*favorites), func(x Interval) bool { return x == iv }) + } else { + *favorites = append(slices.Clone(*favorites), iv) + } + slog.Debug("mujica: interval_selector favorite", "interval", iv, "on", !fav) + } + }) + return chosen +} diff --git a/messages_mktchart.go b/messages_mktchart.go index 899e06f..3930344 100644 --- a/messages_mktchart.go +++ b/messages_mktchart.go @@ -2,33 +2,73 @@ package mujica func init() { addMessages(map[string][2]string{ - "candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"}, - "candlestick_chart.volume": {"Vol", "量"}, - "heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"}, - "heikin_ashi.raw": {"Candles", "原 K 线"}, - "heikin_ashi.switch": {"Candle style", "K 线样式"}, - "compare_symbol.label": {"Compared symbols", "对比品种"}, - "compare_symbol.remove": {"Remove %s", "移除 %s"}, - "price_alert_line.tag": {"Alert %s", "预警 %s"}, - "chart_sync.link": {"Sync with other charts", "与其他图表同步"}, - "chart_sync.linked": {"Synced", "已同步"}, - "chart_sync.unlinked": {"Not synced", "未同步"}, - "indicator_pane.remove": {"Remove %s", "移除 %s"}, - "line_quote_chart.price": {"Price", "价格"}, - "line_quote_chart.average": {"Average", "均价"}, - "line_quote_chart.pre": {"Pre-market", "盘前"}, - "line_quote_chart.after": {"After hours", "盘后"}, - "depth_chart.bids": {"Bids", "买盘"}, - "depth_chart.asks": {"Asks", "卖盘"}, - "depth_chart.bid": {"Bid", "买"}, - "depth_chart.ask": {"Ask", "卖"}, - "depth_chart.total": {"cumulative %s", "累计 %s"}, - "depth_chart.mid": {"Mid", "中间价"}, - "volume_profile.value_area": {"Value area", "价值区"}, - "volume_profile.other": {"Outside", "区外"}, - "volume_profile.poc": {"POC", "控制点"}, - "volume_profile.vah": {"VAH", "价值区上沿"}, - "volume_profile.val": {"VAL", "价值区下沿"}, - "drawing_tools.note": {"Note", "标注"}, + "candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"}, + "candlestick_chart.volume": {"Vol", "量"}, + "heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"}, + "heikin_ashi.raw": {"Candles", "原 K 线"}, + "heikin_ashi.switch": {"Candle style", "K 线样式"}, + "compare_symbol.label": {"Compared symbols", "对比品种"}, + "compare_symbol.remove": {"Remove %s", "移除 %s"}, + "price_alert_line.tag": {"Alert %s", "预警 %s"}, + "chart_sync.link": {"Sync with other charts", "与其他图表同步"}, + "chart_sync.linked": {"Synced", "已同步"}, + "chart_sync.unlinked": {"Not synced", "未同步"}, + "indicator_pane.remove": {"Remove %s", "移除 %s"}, + "line_quote_chart.price": {"Price", "价格"}, + "line_quote_chart.average": {"Average", "均价"}, + "line_quote_chart.pre": {"Pre-market", "盘前"}, + "line_quote_chart.after": {"After hours", "盘后"}, + "depth_chart.bids": {"Bids", "买盘"}, + "depth_chart.asks": {"Asks", "卖盘"}, + "depth_chart.bid": {"Bid", "买"}, + "depth_chart.ask": {"Ask", "卖"}, + "depth_chart.total": {"cumulative %s", "累计 %s"}, + "depth_chart.mid": {"Mid", "中间价"}, + "volume_profile.value_area": {"Value area", "价值区"}, + "volume_profile.other": {"Outside", "区外"}, + "volume_profile.poc": {"POC", "控制点"}, + "volume_profile.vah": {"VAH", "价值区上沿"}, + "volume_profile.val": {"VAL", "价值区下沿"}, + "time_range_selector.custom": {"Custom", "自定义"}, + "time_range_selector.label": {"Time range", "时间范围"}, + "interval_selector.label": {"Interval", "周期"}, + "interval_selector.all": {"All intervals", "全部周期"}, + "interval_selector.minutes": {"Minutes", "分钟"}, + "interval_selector.hours": {"Hours", "小时"}, + "interval_selector.days": {"Days and longer", "日及以上"}, + "interval_selector.favorite": {"Add %s to favorites", "收藏 %s"}, + "interval_selector.unfavorite": {"Remove %s from favorites", "取消收藏 %s"}, + "chart_type_switcher.label": {"Chart type", "图表类型"}, + "chart_type_switcher.type0": {"Candles", "K 线"}, + "chart_type_switcher.type1": {"OHLC", "美国线"}, + "chart_type_switcher.type2": {"Line", "折线"}, + "chart_type_switcher.type3": {"Area", "面积"}, + "chart_type_switcher.type4": {"Heikin-Ashi", "平均 K 线"}, + "candle_countdown.label": {"%s candle closes in %s", "%s K 线 %s 后收盘"}, + "multi_chart_layout.label": {"Chart layout", "图表布局"}, + "multi_chart_layout.count": {"%d charts", "%d 宫格"}, + "multi_chart_layout.cell": {"Chart %d %s", "图表 %d %s"}, + "drawing_toolbar.label": {"Drawing tools", "画线工具"}, + "drawing_toolbar.tool0": {"Select", "选择"}, + "drawing_toolbar.tool1": {"Trend line", "趋势线"}, + "drawing_toolbar.tool2": {"Horizontal line", "水平线"}, + "drawing_toolbar.tool3": {"Fibonacci", "斐波那契"}, + "drawing_toolbar.tool4": {"Rectangle", "矩形"}, + "drawing_toolbar.tool5": {"Text", "文字"}, + "drawing_toolbar.lock": {"Lock drawings", "锁定画线"}, + "drawing_toolbar.unlock": {"Unlock drawings", "解锁画线"}, + "drawing_toolbar.undo": {"Undo", "撤销"}, + "drawing_toolbar.clear": {"Clear drawings", "清除画线"}, + "indicator_selector.button": {"Indicators (%d)", "指标(%d)"}, + "indicator_selector.title": {"Indicators", "指标"}, + "indicator_selector.search": {"Search indicators", "搜索指标"}, + "indicator_selector.available": {"Available", "可添加"}, + "indicator_selector.active": {"On the chart", "已添加"}, + "indicator_selector.overlay": {"Over prices", "主图叠加"}, + "indicator_selector.pane": {"Own pane", "副图"}, + "indicator_selector.add": {"Add", "添加"}, + "indicator_selector.add_named": {"Add %s", "添加 %s"}, + "indicator_selector.none": {"No indicator matches", "没有匹配的指标"}, + "drawing_tools.note": {"Note", "标注"}, }) } diff --git a/multi_chart_layout.go b/multi_chart_layout.go new file mode 100644 index 0000000..1543892 --- /dev/null +++ b/multi_chart_layout.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartCell is one chart of a MultiChartLayout: its symbol, period and view. +type ChartCell struct { + Symbol string + Interval Interval + View MarketView +} + +// MultiChartState is the caller-held grid: how many cells show, all six cells' settings, and the active one. +type MultiChartState struct { + // Count is 1, 2, 4 or 6; zero is 1. + Count int + // Cells keep their settings while hidden, so changing the layout loses nothing. + Cells [6]ChartCell + Active int +} + +// MultiChartLayoutOptions configure a MultiChartLayout. +type MultiChartLayoutOptions struct { + // Height of the grid; zero is 480. + Height float32 +} + +// MultiChartLayout shows 1, 2, 4 or 6 charts in a grid, each built by cell with its own settings. +func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayoutOptions, cell func(i int, cell *ChartCell)) ChoiceResult { + if state == nil || cell == nil { + panic("mujica: MultiChartLayout needs a state and a cell builder") + } + if state.Count == 0 { + state.Count = 1 + } + cols := map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count] + if cols == 0 { + panic(fmt.Sprintf("mujica: MultiChartLayout Count %d must be 1, 2, 4 or 6", state.Count)) + } + if state.Active < 0 || state.Active >= state.Count { + panic(fmt.Sprintf("mujica: MultiChartLayout Active %d outside %d cells", state.Active, state.Count)) + } + k := Tokens(c) + u := Density(c).Unit() + h := opts.Height + if h == 0 { + h = 480 + } + changed := false + root := ui.Column(c).Gap(u * 2).FillWidth() + root.Children(func() { + sel := []int{state.Count} + var items []ToggleItem[int] + for _, n := range []int{1, 2, 4, 6} { + items = append(items, ToggleItem[int]{Value: n, Label: fmt.Sprint(n), Name: fmt.Sprintf(msg(c, "multi_chart_layout.count"), n)}) + } + ui.Row(c).Children(func() { + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "multi_chart_layout.label")}).Changed() { + state.Count, changed = sel[0], true + state.Active = min(state.Active, state.Count-1) + slog.Debug("mujica: multi_chart_layout count", "count", state.Count) + } + }) + rows := (state.Count + cols - 1) / cols + grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth() + grid.Children(func() { + for i := range state.Count { + box := ui.Column(c).Key(i).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2). + Background(k.Surface).Radius(theme.CardRadius).Clip().Role(ui.RoleGroup). + Label(fmt.Sprintf(msg(c, "multi_chart_layout.cell"), i+1, state.Cells[i].Symbol)) + if i == state.Active && state.Count > 1 { + box.Border(2, k.AccentText) + box.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Frame(p, r, 3, 5, k.Ornament) }) + } else { + box.Border(theme.BorderWidth, k.Border) + } + if box.FocusWithin() || box.Clicked() { + if state.Active != i { + state.Active, changed = i, true + } + } + box.Children(func() { cell(i, &state.Cells[i]) }) + } + }) + }) + return ChoiceResult{root, changed} +} diff --git a/time_range_selector.go b/time_range_selector.go new file mode 100644 index 0000000..01e7270 --- /dev/null +++ b/time_range_selector.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// RangePreset names a quick time range. The zero value is Range1D. +type RangePreset int + +const ( + Range1D RangePreset = iota + Range5D + Range1M + Range3M + RangeYTD + Range1Y + Range5Y + RangeCustom +) + +var rangePresetNames = []string{"1D", "5D", "1M", "3M", "YTD", "1Y", "5Y"} + +// MarketRange is a chosen range: a preset, or Custom with its dates. +type MarketRange struct { + Preset RangePreset + // Custom holds the dates when Preset is RangeCustom. + Custom DateRange +} + +// Bounds returns the range's start and end at now, in loc; custom ends include their last day. +func (r MarketRange) Bounds(now time.Time, loc *time.Location) (from, to time.Time) { + now = now.In(loc) + switch r.Preset { + case Range1D: + return now.AddDate(0, 0, -1), now + case Range5D: + return now.AddDate(0, 0, -5), now + case Range1M: + return now.AddDate(0, -1, 0), now + case Range3M: + return now.AddDate(0, -3, 0), now + case RangeYTD: + return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc), now + case Range1Y: + return now.AddDate(-1, 0, 0), now + case Range5Y: + return now.AddDate(-5, 0, 0), now + case RangeCustom: + if r.Custom.IsZero() { + panic("mujica: custom MarketRange without dates") + } + s, e := r.Custom.Start, r.Custom.End + return time.Date(s.Year, s.Month, s.Day, 0, 0, 0, 0, loc), time.Date(e.Year, e.Month, e.Day, 0, 0, 0, 0, loc).AddDate(0, 0, 1) + } + panic(fmt.Sprintf("mujica: unknown RangePreset %d", r.Preset)) +} + +// TimeRangeSelectorOptions configure a TimeRangeSelector. +type TimeRangeSelectorOptions struct { + Disabled bool + // Today marks the calendar's today; zero uses the clock. + Today Date +} + +// TimeRangeSelector chooses a preset range or a custom one; the presets are one Tab stop moved with the arrows. +func TimeRangeSelector(c *ui.Context, value *MarketRange, opts TimeRangeSelectorOptions) ChoiceResult { + if value == nil { + panic("mujica: TimeRangeSelector needs a value") + } + if value.Preset < Range1D || value.Preset > RangeCustom { + panic(fmt.Sprintf("mujica: unknown RangePreset %d", value.Preset)) + } + if value.Preset == RangeCustom && value.Custom.IsZero() { + panic("mujica: custom MarketRange without dates") + } + items := make([]ToggleItem[RangePreset], len(rangePresetNames)+1) + for i, n := range rangePresetNames { + items[i] = ToggleItem[RangePreset]{Value: RangePreset(i), Label: n} + } + items[len(items)-1] = ToggleItem[RangePreset]{Value: RangeCustom, Label: msg(c, "time_range_selector.custom")} + changed := false + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Wrap().Disabled(opts.Disabled) + row.Children(func() { + sel := []RangePreset{value.Preset} + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "time_range_selector.label")}).Changed() && !row.IsDisabled() { + if sel[0] == RangeCustom && value.Custom.IsZero() { + today := datetimeToday(c, opts.Today) + value.Custom = DateRange{Start: today.AddMonths(-1), End: today} + } + value.Preset, changed = sel[0], true + } + if value.Preset == RangeCustom { + if DateRangePicker(c, &value.Custom, DateRangePickerOptions{Today: opts.Today, Max: datetimeToday(c, opts.Today)}).Changed() { + changed = true + } + } + }) + if changed { + slog.Debug("mujica: time_range_selector changed", "preset", value.Preset, "custom", value.Custom) + } + return ChoiceResult{row, changed} +}