diff --git a/bargraph.go b/bargraph.go new file mode 100644 index 0000000..11fa0c7 --- /dev/null +++ b/bargraph.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "image/color" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" +) + +// barGraphCanvas is a custom widget that renders a bar graph +type barGraphCanvas struct { + widget.BaseWidget + stats *DiceStatistics +} + +func newBarGraphCanvas(stats *DiceStatistics) *barGraphCanvas { + graph := &barGraphCanvas{ + stats: stats, + } + graph.ExtendBaseWidget(graph) + return graph +} + +func (b *barGraphCanvas) CreateRenderer() fyne.WidgetRenderer { + b.ExtendBaseWidget(b) + return &barGraphCanvasRenderer{ + graph: b, + } +} + +type barGraphCanvasRenderer struct { + graph *barGraphCanvas + objects []fyne.CanvasObject +} + +func (r *barGraphCanvasRenderer) Layout(size fyne.Size) { + r.Refresh() +} + +func (r *barGraphCanvasRenderer) MinSize() fyne.Size { + return fyne.NewSize(800, 500) +} + +func (r *barGraphCanvasRenderer) Refresh() { + r.objects = []fyne.CanvasObject{} + + if r.graph.stats == nil || len(r.graph.stats.Results) == 0 { + return + } + + stats := r.graph.stats + outcomes := stats.GetSortedOutcomes() + maxPercentage := stats.GetMaxPercentage() + + // Round up maxPercentage to nearest 5% + roundedMaxPercent := math.Ceil(maxPercentage/5) * 5 + if roundedMaxPercent < 5 { + roundedMaxPercent = 5 + } + + // Padding + topPadding := float32(40) + bottomPadding := float32(80) + leftPadding := float32(100) + rightPadding := float32(20) + + graphWidth := float32(800) - leftPadding - rightPadding + graphHeight := float32(500) - topPadding - bottomPadding + + // Background + background := canvas.NewRectangle(color.NRGBA{R: 20, G: 20, B: 20, A: 255}) + background.Move(fyne.NewPos(0, 0)) + background.Resize(fyne.NewSize(800, 500)) + r.objects = append(r.objects, background) + + // Y-axis + yAxisLine := canvas.NewLine(color.White) + yAxisLine.StrokeWidth = 2 + yAxisLine.Move(fyne.NewPos(leftPadding, topPadding)) + yAxisLine.Resize(fyne.NewSize(0, graphHeight)) + r.objects = append(r.objects, yAxisLine) + + // X-axis + xAxisLine := canvas.NewLine(color.White) + xAxisLine.StrokeWidth = 2 + xAxisLine.Move(fyne.NewPos(leftPadding, topPadding+graphHeight)) + xAxisLine.Resize(fyne.NewSize(graphWidth, 0)) + r.objects = append(r.objects, xAxisLine) + + // Title + title := canvas.NewText(fmt.Sprintf("Probability Distribution: %d to %d (%d total outcomes)", stats.MinValue, stats.MaxValue, stats.Total), color.White) + title.TextSize = 16 + title.Move(fyne.NewPos(leftPadding, 10)) + r.objects = append(r.objects, title) + + // Y-axis label + yLabel := canvas.NewText("Probability (%)", color.White) + yLabel.TextSize = 12 + yLabel.Move(fyne.NewPos(10, topPadding+graphHeight/2-30)) + r.objects = append(r.objects, yLabel) + + // X-axis label + xLabel := canvas.NewText("Result Value", color.White) + xLabel.TextSize = 12 + xLabel.Move(fyne.NewPos(leftPadding+graphWidth/2-30, topPadding+graphHeight+50)) + r.objects = append(r.objects, xLabel) + + // Y-axis tick marks and labels + numYTicks := int(roundedMaxPercent/5) + 1 + for i := 0; i <= numYTicks; i++ { + percent := float64(i) * 5 + + yPos := topPadding + graphHeight - (float32(percent/roundedMaxPercent) * graphHeight) + + // Tick mark + tick := canvas.NewLine(color.White) + tick.StrokeWidth = 1 + tick.Move(fyne.NewPos(leftPadding-5, yPos)) + tick.Resize(fyne.NewSize(5, 0)) + r.objects = append(r.objects, tick) + + // Label + label := canvas.NewText(fmt.Sprintf("%.0f%%", percent), color.White) + label.TextSize = 10 + label.Move(fyne.NewPos(leftPadding-50, yPos-7)) + r.objects = append(r.objects, label) + } + + // Draw bars + numBars := len(outcomes) + barWidth := (graphWidth - float32(numBars+1)*2) / float32(numBars) + if barWidth < 2 { + barWidth = 2 + } + + barSpacing := float32(2) + + for i, value := range outcomes { + percentage := stats.Percentages[value] + + // Bar height proportional to percentage + barHeight := (float32(percentage) / float32(roundedMaxPercent)) * graphHeight + + // X position + xPos := leftPadding + float32(i)*(barWidth+barSpacing*2) + barSpacing + + // Draw bar + bar := canvas.NewRectangle(color.NRGBA{R: 100, G: 180, B: 255, A: 255}) + bar.Move(fyne.NewPos(xPos, topPadding+graphHeight-barHeight)) + bar.Resize(fyne.NewSize(barWidth, barHeight)) + r.objects = append(r.objects, bar) + + // X-axis label + label := canvas.NewText(fmt.Sprintf("%d", value), color.White) + label.TextSize = 10 + label.Move(fyne.NewPos(xPos, topPadding+graphHeight+10)) + r.objects = append(r.objects, label) + } +} + +func (r *barGraphCanvasRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *barGraphCanvasRenderer) Destroy() { +} + +// ShowStatisticsWindow creates and shows a statistics window for the given expression +func ShowStatisticsWindow(expression string) { + stats, err := CalculateDiceStatistics(expression) + if err != nil { + fmt.Printf("Error calculating statistics: %v\n", err) + return + } + + // Create the bar graph + graph := newBarGraphCanvas(stats) + + // Create and show the window + window := fyne.CurrentApp().NewWindow("Statistics: " + expression) + window.SetContent(graph) + window.Resize(fyne.NewSize(800, 500)) + window.Show() +} diff --git a/history.go b/history.go index 620a5cc..8454f47 100644 --- a/history.go +++ b/history.go @@ -19,15 +19,21 @@ type historyItemRenderer struct { equationLabel *customLabel diceRollsLabel *customLabel resultLabel *customLabel + container fyne.CanvasObject objects []fyne.CanvasObject } func (r *historyItemRenderer) MinSize() fyne.Size { - return r.objects[0].MinSize() + minSize := r.container.MinSize() + // Ensure a minimum height so items don't overlap + if minSize.Height < 90 { + minSize = fyne.NewSize(minSize.Width, 90) + } + return minSize } func (r *historyItemRenderer) Layout(size fyne.Size) { - r.objects[0].Resize(size) + r.container.Resize(size) } func (r *historyItemRenderer) ApplyTheme() { @@ -40,6 +46,7 @@ func (r *historyItemRenderer) Refresh() { r.equationLabel.Refresh() r.diceRollsLabel.Refresh() r.resultLabel.Refresh() + r.container.Refresh() } func (r *historyItemRenderer) Objects() []fyne.CanvasObject { @@ -51,7 +58,17 @@ func (r *historyItemRenderer) Destroy() { type historyItem struct { widget.BaseWidget - calc *calculation + calc *calculation + onTapped func(equation string) +} + +func (h *historyItem) Tapped(*fyne.PointEvent) { + if h.onTapped != nil && h.calc != nil { + h.onTapped(h.calc.equation) + } +} + +func (h *historyItem) TappedSecondary(*fyne.PointEvent) { } func (h *historyItem) CreateRenderer() fyne.WidgetRenderer { @@ -75,6 +92,7 @@ func (h *historyItem) CreateRenderer() fyne.WidgetRenderer { equationLabel: equationLabel, diceRollsLabel: diceRollsLabel, resultLabel: resultLabel, + container: layout, objects: []fyne.CanvasObject{layout}, } } @@ -89,3 +107,9 @@ func newHistoryItem(c *calculation) *historyItem { item.ExtendBaseWidget(item) return item } + +func newHistoryItemWithCallback(c *calculation, onTapped func(equation string)) *historyItem { + item := &historyItem{calc: c, onTapped: onTapped} + item.ExtendBaseWidget(item) + return item +} diff --git a/main.go b/main.go index 3096578..ddff70e 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,9 @@ func main() { return len(calculations) }, func() fyne.CanvasObject { - return newHistoryItem(&calculation{}) + return newHistoryItemWithCallback(&calculation{}, func(equation string) { + diceInputEntry.SetText(equation) + }) }, func(i widget.ListItemID, o fyne.CanvasObject) { o.(*historyItem).SetCalculation(calculations[i]) @@ -73,7 +75,7 @@ func main() { diceInputEntry.SetText(diceInputEntry.Text + "H") }), newCustomButton2("dX", func() { - diceInputEntry.SetText(diceInputEntry.Text + "dX") + diceInputEntry.SetText(diceInputEntry.Text + "d") }), newCustomButton2("d4", func() { diceInputEntry.SetText(diceInputEntry.Text + "d4") @@ -127,7 +129,11 @@ func main() { diceInputEntry.SetText(diceInputEntry.Text + "+") }), newCustomButton2("📊", func() { - // TODO: Implement statistics logic + diceInput := strings.TrimSpace(diceInputEntry.Text) + if diceInput == "" { + return + } + ShowStatisticsWindow(diceInput) }), newCustomButton2(".", func() { diceInputEntry.SetText(diceInputEntry.Text + ".") diff --git a/statistics.go b/statistics.go new file mode 100644 index 0000000..4913c14 --- /dev/null +++ b/statistics.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// DiceStatistics holds the theoretical statistics for a dice roll +type DiceStatistics struct { + MinValue int + MaxValue int + Results map[int]int // outcome -> count of ways to achieve it + Total int // total number of possible outcomes + Percentages map[int]float64 // outcome -> percentage +} + +// CalculateDiceStatistics calculates the theoretical distribution of possible outcomes for a dice expression +func CalculateDiceStatistics(expression string) (*DiceStatistics, error) { + expression = strings.TrimSpace(expression) + if expression == "" { + return nil, fmt.Errorf("empty expression") + } + + // Parse the expression to extract terms + terms, err := parseTerms(expression) + if err != nil { + return nil, err + } + + // Calculate all possible outcomes and their frequencies + outcomes := calculateOutcomeDistribution(terms) + + if len(outcomes) == 0 { + return nil, fmt.Errorf("no valid outcomes for expression") + } + + // Find min and max + minVal := -1 + maxVal := -1 + totalCount := 0 + + for value, count := range outcomes { + totalCount += count + if minVal == -1 || value < minVal { + minVal = value + } + if maxVal == -1 || value > maxVal { + maxVal = value + } + } + + // Calculate percentages + percentages := make(map[int]float64) + for value, count := range outcomes { + percentages[value] = (float64(count) / float64(totalCount)) * 100 + } + + return &DiceStatistics{ + MinValue: minVal, + MaxValue: maxVal, + Results: outcomes, + Total: totalCount, + Percentages: percentages, + }, nil +} + +// Term represents a single term in the expression (dice roll or constant) +type Term struct { + isDice bool + count int // number of dice + sides int // sides per die + modifier string // "" for sum, "H" for highest, "L" for lowest + value int // constant value if not dice + op string // operation before this term: "+", "-" +} + +// parseTerms parses a dice expression into terms +func parseTerms(expression string) ([]Term, error) { + var terms []Term + + // Split by + and -, keeping the operators + parts := regexp.MustCompile(`([+\-])`).Split(expression, -1) + + currentOp := "+" + dicePattern := regexp.MustCompile(`^(\d*)d(\d+)([HL])?$`) + + for _, part := range parts { + part = strings.TrimSpace(part) + + if part == "" { + continue + } + + // Check if this is an operator + if part == "+" || part == "-" { + currentOp = part + continue + } + + // Try to match dice notation + matches := dicePattern.FindStringSubmatch(part) + if matches != nil { + count := 1 + if matches[1] != "" { + c, err := strconv.Atoi(matches[1]) + if err != nil { + return nil, err + } + count = c + } + + sides, err := strconv.Atoi(matches[2]) + if err != nil { + return nil, err + } + + if sides <= 0 || count <= 0 { + return nil, fmt.Errorf("invalid dice: %dd%d", count, sides) + } + + modifier := matches[3] + + terms = append(terms, Term{ + isDice: true, + count: count, + sides: sides, + modifier: modifier, + op: currentOp, + }) + currentOp = "+" + } else { + // Try to parse as constant + val, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid term: %s", part) + } + + terms = append(terms, Term{ + isDice: false, + value: val, + op: currentOp, + }) + currentOp = "+" + } + } + + return terms, nil +} + +// calculateOutcomeDistribution calculates all possible outcomes and their frequencies +func calculateOutcomeDistribution(terms []Term) map[int]int { + // Start with base case: single outcome of 0 with 1 way to achieve it + outcomes := map[int]int{0: 1} + + for _, term := range terms { + outcomes = applyTerm(outcomes, term) + } + + return outcomes +} + +// applyTerm applies a term to the current outcomes distribution +func applyTerm(currentOutcomes map[int]int, term Term) map[int]int { + newOutcomes := make(map[int]int) + + if term.isDice { + // Get all possible values for this dice roll + diceOutcomes := getDiceOutcomes(term.count, term.sides, term.modifier) + + // Combine with current outcomes + for currentVal, currentCount := range currentOutcomes { + for diceVal, diceCount := range diceOutcomes { + var resultVal int + if term.op == "-" { + resultVal = currentVal - diceVal + } else { + resultVal = currentVal + diceVal + } + + newOutcomes[resultVal] += currentCount * diceCount + } + } + } else { + // Constant value + for currentVal, currentCount := range currentOutcomes { + var resultVal int + if term.op == "-" { + resultVal = currentVal - term.value + } else { + resultVal = currentVal + term.value + } + + newOutcomes[resultVal] += currentCount + } + } + + return newOutcomes +} + +// getDiceOutcomes returns a map of all possible outcomes for a dice roll and their frequencies +func getDiceOutcomes(count int, sides int, modifier string) map[int]int { + outcomes := make(map[int]int) + + if modifier == "H" { + // Keep only the highest die + generateHighestOutcomes(count, sides, []int{}, outcomes) + } else if modifier == "L" { + // Keep only the lowest die + generateLowestOutcomes(count, sides, []int{}, outcomes) + } else { + // Sum all dice + generateSumOutcomes(count, sides, []int{}, outcomes) + } + + return outcomes +} + +// generateSumOutcomes recursively generates all sums +func generateSumOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { + if remaining == 0 { + sum := 0 + for _, val := range current { + sum += val + } + outcomes[sum]++ + return + } + + for die := 1; die <= sides; die++ { + generateSumOutcomes(remaining-1, sides, append(current, die), outcomes) + } +} + +// generateHighestOutcomes recursively generates all highest-die outcomes +func generateHighestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { + if remaining == 0 { + highest := 0 + for _, val := range current { + if val > highest { + highest = val + } + } + outcomes[highest]++ + return + } + + for die := 1; die <= sides; die++ { + generateHighestOutcomes(remaining-1, sides, append(current, die), outcomes) + } +} + +// generateLowestOutcomes recursively generates all lowest-die outcomes +func generateLowestOutcomes(remaining int, sides int, current []int, outcomes map[int]int) { + if remaining == 0 { + lowest := sides + 1 + for _, val := range current { + if val < lowest { + lowest = val + } + } + outcomes[lowest]++ + return + } + + for die := 1; die <= sides; die++ { + generateLowestOutcomes(remaining-1, sides, append(current, die), outcomes) + } +} + +// GetSortedOutcomes returns sorted unique outcomes +func (s *DiceStatistics) GetSortedOutcomes() []int { + var outcomes []int + for value := range s.Results { + outcomes = append(outcomes, value) + } + sort.Ints(outcomes) + return outcomes +} + +// GetMaxPercentage returns the maximum percentage value +func (s *DiceStatistics) GetMaxPercentage() float64 { + maxPercentage := 0.0 + for _, percentage := range s.Percentages { + if percentage > maxPercentage { + maxPercentage = percentage + } + } + return maxPercentage +} diff --git a/ui.go b/ui.go index 610d604..63a2c3b 100644 --- a/ui.go +++ b/ui.go @@ -178,61 +178,51 @@ type customEntry struct { } func (e *customEntry) CreateRenderer() fyne.WidgetRenderer { - e.ExtendBaseWidget(e) - - text := canvas.NewText(e.Text, color.White) - text.TextSize = e.TextSize - - placeholder := canvas.NewText(e.PlaceHolder, theme.PlaceHolderColor()) - placeholder.TextSize = e.TextSize - placeholder.TextStyle = e.TextStyle - - objects := []fyne.CanvasObject{placeholder, text} + // Call the parent's CreateRenderer to ensure proper initialization + renderer := e.Entry.CreateRenderer() + // Wrap the parent renderer to add our custom TextSize return &customEntryRenderer{ - entry: e, - text: text, - placeholder: placeholder, - objects: objects, + entry: e, + parentRenderer: renderer, } } type customEntryRenderer struct { - entry *customEntry - text *canvas.Text - placeholder *canvas.Text - objects []fyne.CanvasObject + entry *customEntry + parentRenderer fyne.WidgetRenderer } func (r *customEntryRenderer) Layout(size fyne.Size) { - r.text.Resize(size) - r.placeholder.Resize(size) + // Update TextSize on all canvas text objects + for _, obj := range r.parentRenderer.Objects() { + if text, ok := obj.(*canvas.Text); ok { + text.TextSize = r.entry.TextSize + } + } + r.parentRenderer.Layout(size) } func (r *customEntryRenderer) MinSize() fyne.Size { - return r.text.MinSize() + return r.parentRenderer.MinSize() } func (r *customEntryRenderer) Refresh() { - r.text.Text = r.entry.Text - r.text.TextSize = r.entry.TextSize - r.text.Refresh() - - r.placeholder.Text = r.entry.PlaceHolder - r.placeholder.TextSize = r.entry.TextSize - if r.entry.Text == "" { - r.placeholder.Show() - } else { - r.placeholder.Hide() + // Update TextSize on all canvas text objects + for _, obj := range r.parentRenderer.Objects() { + if text, ok := obj.(*canvas.Text); ok { + text.TextSize = r.entry.TextSize + } } - r.placeholder.Refresh() + r.parentRenderer.Refresh() } func (r *customEntryRenderer) Objects() []fyne.CanvasObject { - return r.objects + return r.parentRenderer.Objects() } func (r *customEntryRenderer) Destroy() { + r.parentRenderer.Destroy() } func newCustomEntry(size float32) *customEntry {